chore: merge release 1.8.0 (#12088)
* fix: Fixes Kubernetes deployment crash on runtime_port parsing (#11968) (#11975) * feat: add runtime port validation for Kubernetes service discovery * test: add unit tests for runtime port validation in Settings * fix: improve runtime port validation to handle exceptions and edge cases Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@logspace.ai> * fix(frontend): show delete option for default session when it has messages (#11969) * feat: add documentation link to Guardrails component (#11978) * feat: add documentation link to Guardrails component * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * feat: traces v0 (#11689) (#11983) * feat: traces v0 v0 for traces includes: - filters: status, token usage range and datatime - accordian rows per trace Could add: - more filter options. Ecamples: session_id, trace_id and latency range * fix: token range * feat: create sidebar buttons for logs and trace add sidebar buttons for logs and trace remove lods canvas control * fix: fix duplicate trace ID insertion hopefully fix duplicate trace ID insertion on windows * fix: update tests and alembic tables for uts update tests and alembic tables for uts * chore: add session_id * chore: allo grouping by session_id and flow_id * chore: update race input output * chore: change run name to flow_name - flow_id was flow_name - trace_id now flow_name - flow_id * facelift * clean up and add testcases * clean up and add testcases * merge Alembic detected multiple heads * [autofix.ci] apply automated fixes * improve testcases * remodel files * chore: address gabriel simple changes address gabriel simple changes in traces.py and native.py * clean up and testcases * chore: address OTel and PG status comments https://github.com/langflow-ai/langflow/pull/11689#discussion_r2854630438 https://github.com/langflow-ai/langflow/pull/11689#discussion_r2854630446 * chore: OTel span naming convention model name is now set using name = f"{operation} {model_name}" if model_name else operation * add traces * feat: use uv sources for CPU-only PyTorch (#11884) * feat: use uv sources for CPU-only PyTorch Configure [tool.uv.sources] with pytorch-cpu index to avoid ~6GB CUDA dependencies in Docker images. This replaces hardcoded wheel URLs with a cleaner index-based approach. - Add pytorch-cpu index with explicit = true - Add torch/torchvision to [tool.uv.sources] - Add explicit torch/torchvision deps to trigger source override - Regenerate lockfile without nvidia/cuda/triton packages - Add required-environments for multi-platform support * fix: update regex to only replace name in [project] section The previous regex matched all lines starting with `name = "..."`, which incorrectly renamed the UV index `pytorch-cpu` to `langflow-nightly` during nightly builds. This caused `uv lock` to fail with: "Package torch references an undeclared index: pytorch-cpu" The new regex specifically targets the name field within the [project] section only, avoiding unintended replacements in other sections like [[tool.uv.index]]. * style: fix ruff quote style * fix: remove required-environments to fix Python 3.13 macOS x86_64 CI The required-environments setting was causing hard failures when packages like torch didn't have wheels for specific platform/Python combinations. Without this setting, uv resolves optimistically and handles missing wheels gracefully at runtime instead of failing during resolution. --------- * LE-270: Hydration and Console Log error (#11628) * LE-270: add fix hydration issues * LE-270: fix disable field on max token on language model --------- * test: add wait for selector in mcp server tests (#11883) * Add wait for selector in mcp server tests * [autofix.ci] apply automated fixes * Add more awit for selectors * [autofix.ci] apply automated fixes --------- * fix: reduce visual lag in frontend (#11686) * Reduce lag in frontend by batching react events and reducing minimval visual build time * Cleanup * [autofix.ci] apply automated fixes * add tests and improve code read * [autofix.ci] apply automated fixes * Remove debug log --------- * feat: lazy load imports for language model component (#11737) * Lazy load imports for language model component Ensures that only the necessary dependencies are required. For example, if OpenAI provider is used, it will now only import langchain_openai, rather than requiring langchain_anthropic, langchain_ibm, etc. * Add backwards-compat functions * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Add exception handling * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * comp index * docs: azure default temperature (#11829) * change-azure-openai-default-temperature-to-1.0 * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * [autofix.ci] apply automated fixes --------- * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * fix unit test? * add no-group dev to docker builds * [autofix.ci] apply automated fixes --------- * feat: generate requirements.txt from dependencies (#11810) * Base script to generate requirements Dymanically picks dependency for LanguageM Comp. Requires separate change to remove eager loading. * Lazy load imports for language model component Ensures that only the necessary dependencies are required. For example, if OpenAI provider is used, it will now only import langchain_openai, rather than requiring langchain_anthropic, langchain_ibm, etc. * Add backwards-compat functions * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Add exception handling * Add CLI command to create reqs * correctly exclude langchain imports * Add versions to reqs * dynamically resolve provider imports for language model comp * Lazy load imports for reqs, some ruff fixes * Add dynamic resolves for embedding model comp * Add install hints * Add missing provider tests; add warnings in reqs script * Add a few warnings and fix install hint * update comments add logging * Package hints, warnings, comments, tests * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * Add alias for watsonx * Fix anthropic for basic prompt, azure mapping * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * ruff * [autofix.ci] apply automated fixes * test formatting * ruff * [autofix.ci] apply automated fixes --------- * fix: add handle to file input to be able to receive text (#11825) * changed base file and file components to support muitiple files and files from messages * update component index * update input file component to clear value and show placeholder * updated starter projects * [autofix.ci] apply automated fixes * updated base file, file and video file to share robust file verification method * updated component index * updated templates * fix whitespaces * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * add file upload test for files fed through the handle * [autofix.ci] apply automated fixes * added tests and fixed things pointed out by revies * update component index * fixed test * ruff fixes * Update component_index.json * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * updated component index * updated component index * removed handle from file input * Added functionality to use multiple files on the File Path, and to allow files on the langflow file system. * [autofix.ci] apply automated fixes * fixed lfx test * build component index --------- * docs: Add AGENTS.md development guide (#11922) * add AGENTS.md rule to project * change to agents-example * remove agents.md * add example description * chore: address cris I1 comment address cris I1 comment * chore: address cris I5 address cris I5 * chore: address cris I6 address cris I6 * chore: address cris R7 address cris R7 * fix testcase * chore: address cris R2 address cris R2 * restructure insight page into sidenav * added header and total run node * restructing branch * chore: address gab otel model changes address gab otel model changes will need no migration tables * chore: update alembic migration tables update alembic migration tables after model changes * add empty state for gropu sessions * remove invalid mock * test: update and add backend tests update and add backend tests * chore: address backend code rabbit comments address backend code rabbit comments * chore: address code rabbit frontend comments address code rabbit frontend comments * chore: test_native_tracer minor fix address c1 test_native_tracer minor fix address c1 * chore: address C2 + C3 address C2 + C3 * chore: address H1-H5 address H1-H5 * test: update test_native_tracer update test_native_tracer * fixes * chore: address M2 address m2 * chore: address M1 address M1 * dry changes, factorization * chore: fix 422 spam and clean comments fix 422 spam and clean comments * chore: address M12 address M12 * chore: address M3 address M3 * chore: address M4 address M4 * chore: address M5 address M5 * chore: clean up for M7, M9, M11 clean up for M7, M9, M11 * chore: address L2,L4,L5,L6 + any test address L2,L4,L5 and L6 + any test * chore: alembic + comment clean up alembic + comment clean up * chore: remove depricated test_traces file remove depricated test_traces file. test have all been moved to test_traces_api.py * fix datetime * chore: fix test_trace_api ge=0 is allowed now fix test_trace_api ge=0 is allowed now * chore: remove unused traces cost flow remove unused traces cost flow * fix traces test * fix traces test * fix traces test * fix traces test * fix traces test * chore: address gabriels otel coment address gabriels otel coment latest --------- Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@mac.war.can.ibm.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Ram Gopal Srikar Katakam <44802869+RamGopalSrikar@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: olayinkaadelakun <olayinka.adelakun@ibm.com> Co-authored-by: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com> Co-authored-by: cristhianzl <cristhian.lousa@gmail.com> Co-authored-by: Hamza Rashid <74062092+HzaRashid@users.noreply.github.com> Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Co-authored-by: Edwin Jose <edwin.jose@datastax.com> Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com> * fix(test): Fix superuser timeout test errors by replacing heavy clien… (#11982) fix(test): Fix superuser timeout test errors by replacing heavy client fixture (#11972) * fix super user timeout test error * fix fixture db test * remove canary test * [autofix.ci] apply automated fixes * flaky test --------- Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * refactor(components): Replace eager import with lazy loading in agentics module (#11974) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: add ondelete=CASCADE to TraceBase.flow_id to match migration (#12002) * fix: add ondelete=CASCADE to TraceBase.flow_id to match migration The migration file creates the trace table's flow_id foreign key with ondelete="CASCADE", but the model was missing this parameter. This mismatch caused the migration validator to block startup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add defensive migration to ensure trace.flow_id has CASCADE Adds a migration that ensures the trace.flow_id foreign key has ondelete=CASCADE. While the original migration already creates it with CASCADE, this provides a safety net for any databases that may have gotten into an inconsistent state. * fix: dynamically find FK constraint name in migration The original migration did not name the FK constraint, so it gets an auto-generated name that varies by database. This fix queries the database to find the actual constraint name before dropping it. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * fix: LE-456 - Update ButtonSendWrapper to handle building state and improve button functionality (#12000) * fix: Update ButtonSendWrapper to handle building state and improve button functionality * fix(frontend): rename stop button title to avoid Playwright selector conflict The "Stop building" title caused getByRole('button', { name: 'Stop' }) to match two elements, breaking Playwright tests in shards 19, 20, 22, 25. Renamed to "Cancel" to avoid the collision with the no-input stop button. * Fix: pydantic fail because output is list, instead of a dict (#11987) pydantic fail because output is list, instead of a dict Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> * refactor: Update guardrails icons (#12016) * Update guardrails.py Changing the heuristic threshold icons. The field was using the default icons. I added icons related to the security theme. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com> * feat(ui): Replace Show column toggle with eye icon in advanced dialog (#12028) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix(ui): Prevent auto-focus and tooltip on dialog close button (#12027) * fix: reset button (#12024) fix reset button Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> * fix: Handle message inputs when ingesting knowledge (#11988) * fix: Handle message inputs when ingesting knowledge * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * Update test_ingestion.py * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix(ui): add error handling for invalid JSON uploads via upload button (#11985) * fix(ui): add error handling for invalid JSON uploads via upload button * feat(frontend): added new test for file upload * feat(frontend): added new test for file upload * fix(ui): Add array validation for provider variables mapping (#12032) * fix: LM span is now properly parent of ChatOpenAI (#12012) * fix: LM span is now properly parent of ChatOpenAI Before LM span and ChatOpenAI span where both considered parents so they where being counted twice in token counts and other sumations Now LM span is properly the parent of ChatOpenAI span so they are not accidently counted twice * chore: clean up comments clean up comments * chore: incase -> incase incase -> incase * fix: Design fix for traces (#12021) * fix: LM span is now properly parent of ChatOpenAI Before LM span and ChatOpenAI span where both considered parents so they where being counted twice in token counts and other sumations Now LM span is properly the parent of ChatOpenAI span so they are not accidently counted twice * chore: clean up comments clean up comments * chore: incase -> incase incase -> incase * design fix * fix testcases * fix header * fix testcase --------- Co-authored-by: Adam Aghili <Adam.Aghili@ibm.com> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@mac.war.can.ibm.com> * fix: Add file upload extension filter for multi-select and folders (#12034) * fix: plaground - inspection panel feedback (#12013) * fix: update layout and variant for file previews in chat messages * fix: update background color to 'bg-muted' in chat header and input wrapper components * refactor(CanvasControls): remove unused inspection panel logic and clean up code * fix: remove 'bg-muted' class from chat header and add 'bg-primary-foreground' to chat sidebar * fix: add Escape key functionality to close sidebar * fix: playground does not scroll down to the latest user message upon … (#12040) fix: playground does not scroll down to the latest user message upon sending (Regression) (#12006) * fixes scroll is on input message * feat: re-engage Safari sticky scroll mode when user sends message Add custom event 'langflow-scroll-to-bottom' to force SafariScrollFix back into sticky mode when user sends a new message. This ensures the chat scrolls to bottom even if user had scrolled up, fixing behavior where Safari's scroll fix would remain disengaged after manual scrolling. Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> * fix: knowledge Base Table — Row Icon Appears Clipped/Cut for Some Ent… (#12039) fix: knowledge Base Table — Row Icon Appears Clipped/Cut for Some Entries (#12009) * removed book and added file. makes more sense * feat: add accent-blue color to design system and update knowledge base file icon - Add accent-blue color variables to light and dark themes in CSS - Register accent-blue in Tailwind config with DEFAULT and foreground variants - Update knowledge base file icon fallback color from hardcoded text-blue-500 to text-accent-blue-foreground Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> * fix: MCP Server Modal Improvements (#12017) (#12038) * fixes to the mcp modal for style * style: convert double quotes to single quotes in baseModal component * style: convert double quotes to single quotes in addMcpServerModal component Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> * fix: change loop description (#12018) (#12037) * fix: change loop description (#12018) * docs: simplify Loop component description in starter project and component index * [autofix.ci] apply automated fixes * style: format Loop component description to comply with line length limits * fixed component index * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * [autofix.ci] apply automated fixes --------- Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * feat: add mutual exclusivity between ChatInput and Webhook components (#12036) * feat: add mutual exclusivity between ChatInput and Webhook components * [autofix.ci] apply automated fixes * refactor: address PR feedback - add comprehensive tests and constants * [autofix.ci] apply automated fixes * refactor: address PR feedback - add comprehensive tests and constants * [autofix.ci] apply automated fixes --------- Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: mcp config issue (#12045) * Only process dict template fields In json_schema_from_flow, guard access to template field properties by checking isinstance(field_data, dict) before calling .get(). This replaces the previous comparison to the string "Component" and prevents attribute errors when template entries are non-dict values, ensuring only dict-type fields with show=True and not advanced are included in the generated schema. * Check and handle MCP server URL changes When skipping creation of an existing MCP server for a user's starter projects, first compute the expected project URL and compare it to URLs found in the existing config args. If the URL matches, keep skipping and log that the server is correctly configured; if the URL differs (e.g., port changed on restart), log the difference and allow the flow to update the server configuration. Adds URL extraction and improved debug messages to support automatic updates when server endpoints change. --------- Co-authored-by: Ram Gopal Srikar Katakam <44802869+RamGopalSrikar@users.noreply.github.com> * fix: langflow breaks when we click on the last level of the chain (#12044) Langflow breaks when we click on the last level of the chain. Co-authored-by: Olayinka Adelakun <olayinkaadelakun@mac.war.can.ibm.com> * fix: standardize "README" title and update API key configuration note… (#12051) fix: standardize "README" title and update API key configuration notes in 3 main flow templates (#12005) * updated for README * chore: update secrets baseline with new line numbers * fixed test Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> * fix: Cherry-pick Knowledge Base Improvements (le-480) into release-1.8.0 (#12052) * fix: improve knowledge base UI consistency and pagination handling - Change quote style from double to single quotes throughout knowledge base components - Update "Hide Sources" button label to "Hide Configuration" for clarity - Restructure SourceChunksPage layout to use xl:container for consistent spacing - Add controlled page input state with validation on blur and Enter key - Synchronize page input field with pagination controls to prevent state drift - Reset page input to "1" when changing page * refactor: extract page input commit logic into reusable function Extract page input validation and commit logic from handlePageInputBlur and handlePageInputKeyDown into a shared commitPageInput function to eliminate code duplication. * fix(ui): ensure session deletion properly clears backend and cache (#12043) * fix(ui): ensure session deletion properly clears backend and cache * fix: resolved PR comments and add new regression test * fix: resolved PR comments and add new regression test * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: Check template field is dict before access (#12035) Only process dict template fields In json_schema_from_flow, guard access to template field properties by checking isinstance(field_data, dict) before calling .get(). This replaces the previous comparison to the string "Component" and prevents attribute errors when template entries are non-dict values, ensuring only dict-type fields with show=True and not advanced are included in the generated schema. Co-authored-by: Ram Gopal Srikar Katakam <44802869+RamGopalSrikar@users.noreply.github.com> * fix: hide Knowledge Ingestion component and rename Retrieval to Knowledge Base (#12054) * fix: hide Knowledge Ingestion component and rename Retrieval to Knowledge Base Move ingestion component to deactivated folder so it's excluded from dynamic discovery. Rename KnowledgeRetrievalComponent to KnowledgeBaseComponent with display_name "Knowledge Base". Update all exports, component index, starter project, frontend sidebar filter, and tests. * fix: update test_ingestion import to use deactivated module path * fix: skip deactivated KnowledgeIngestion test suite * [autofix.ci] apply automated fixes * fix: standardize formatting and indentation in StepperModal component --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: Embedding Model Field Stuck in Infinite Loading When No Model Provider is Configured (release-1.8.0) (#12053) * fix: add showEmptyState prop to ModelInputComponent for better UX when no models are enabled * style: convert double quotes to single quotes in modelInputComponent * fixes refresh and kb blocker * style: convert double quotes to single quotes in ModelTrigger component * style: convert double quotes to single quotes in model provider components - Convert all double quotes to single quotes in use-get-model-providers.ts and ModelProvidersContent.tsx - Remove try-catch block in getModelProvidersFn to let errors propagate for React Query retry and stale data preservation - Add flex-shrink-0 to provider list container to prevent layout issues * fix: Close model dropdown popover before refresh to prevent width glitch (#12067) fix(test): Reduce response length assertions in flaky integration tests (#12057) * feat: Add PDF and DOCX ingestion support for Knowledge Bases (#12064) * add pdf and docx for knowledge bases * ruff style checker fix * fix jest test * fix: Use global LLM in knowledge retrieval (#11989) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com> fix(test): Reduce response length assertions in flaky integration tests (#12057) * fix: Regenerate the knowledge retrieval template (#12070) * fix: refactor KnowledgeBaseEmptyState to use optimistic updates hook (#12069) * fix: refactor KnowledgeBaseEmptyState to use optimistic updates hook * updated tst * fix: Apply provider variable config to Agent build_config (#12050) * Apply provider variable config to Agent build_config Import and use apply_provider_variable_config_to_build_config in the Agent component so provider-specific variable settings (advanced/required/info/env fallbacks) are applied to the build_config. Provider-specific fields (e.g. base_url_ibm_watsonx, project_id) are hidden/disabled by default before applying the provider config. Updated embedded agent code in starter project JSONs and bumped their code_hashes accordingly. * [autofix.ci] apply automated fixes * update tests --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com> Co-authored-by: himavarshagoutham <himavarshajan17@gmail.com> * LE-489: KB Metrics calculation batch caculator (#12049) Fixed metric calculator to be more robust and scalable. * [autofix.ci] apply automated fixes * Restore merge migration to fix divergent heads * Update model.py * Rebuild component index and starter projects * Update src/frontend/src/pages/FlowPage/components/flowSidebarComponent/index.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/frontend/src/components/ui/__tests__/dialog.test.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Always use sa column specifications in the model --------- Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@logspace.ai> Co-authored-by: keval shah <kevalvirat@gmail.com> Co-authored-by: Antônio Alexandre Borges Lima <104531655+AntonioABLima@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@mac.war.can.ibm.com> Co-authored-by: Ram Gopal Srikar Katakam <44802869+RamGopalSrikar@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: olayinkaadelakun <olayinka.adelakun@ibm.com> Co-authored-by: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com> Co-authored-by: cristhianzl <cristhian.lousa@gmail.com> Co-authored-by: Hamza Rashid <74062092+HzaRashid@users.noreply.github.com> Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Co-authored-by: Edwin Jose <edwin.jose@datastax.com> Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com> Co-authored-by: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com> Co-authored-by: Lucas Democh <ldgoularte@gmail.com> Co-authored-by: Eric Hare <ericrhare@gmail.com> Co-authored-by: Debojit Kaushik <Kaushik.debojit@gmail.com> Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Co-authored-by: Janardan Singh Kavia <janardankavia@ibm.com> Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local> Co-authored-by: himavarshagoutham <himavarshajan17@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
1467
.secrets.baseline
1467
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ from chromadb.config import Settings
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_core.documents import Document
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from lfx.base.data.utils import extract_text_from_bytes
|
||||
from lfx.base.models.unified_models import get_embedding_model_options
|
||||
from lfx.components.models_and_agents.embedding_model import EmbeddingModelComponent
|
||||
from lfx.log import logger
|
||||
@@ -162,15 +163,30 @@ class KBAnalysisHelper:
|
||||
metadata["chunks"] = collection.count()
|
||||
|
||||
if metadata["chunks"] > 0:
|
||||
results = collection.get(include=["documents", "metadatas"])
|
||||
source_chunks = pd.DataFrame({"document": results["documents"], "metadata": results["metadatas"]})
|
||||
total_words = 0
|
||||
total_characters = 0
|
||||
# Use a robust batch size to avoid SQLite limits and memory pressure
|
||||
batch_size = 5000
|
||||
|
||||
# Chroma collections always return the text content within the 'documents' field
|
||||
words, characters = KBAnalysisHelper._calculate_text_metrics(source_chunks, ["document"])
|
||||
metadata["words"] = words
|
||||
metadata["characters"] = characters
|
||||
for offset in range(0, metadata["chunks"], batch_size):
|
||||
results = collection.get(
|
||||
include=["documents"],
|
||||
limit=batch_size,
|
||||
offset=offset,
|
||||
)
|
||||
if not results["documents"]:
|
||||
break
|
||||
|
||||
# Chroma collections always return the text content within the 'documents' field
|
||||
source_chunks = pd.DataFrame({"document": results["documents"]})
|
||||
words, characters = KBAnalysisHelper._calculate_text_metrics(source_chunks, ["document"])
|
||||
total_words += words
|
||||
total_characters += characters
|
||||
|
||||
metadata["words"] = total_words
|
||||
metadata["characters"] = total_characters
|
||||
metadata["avg_chunk_size"] = (
|
||||
round(characters / metadata["chunks"], 1) if metadata["chunks"] > 0 else 0.0
|
||||
round(total_characters / metadata["chunks"], 1) if metadata["chunks"] > 0 else 0.0
|
||||
)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError, chromadb.errors.ChromaError) as e:
|
||||
logger.debug(f"Metrics update failed for {kb_path.name}: {e}")
|
||||
@@ -330,7 +346,7 @@ class KBIngestionHelper:
|
||||
job_id_str = str(task_job_id)
|
||||
for file_name, file_content in files_data:
|
||||
await logger.ainfo("Starting ingestion of %s for %s", file_name, kb_name)
|
||||
content = file_content.decode("utf-8", errors="ignore")
|
||||
content = extract_text_from_bytes(file_name, file_content)
|
||||
if not content.strip():
|
||||
continue
|
||||
|
||||
|
||||
@@ -357,12 +357,25 @@ async def auto_configure_starter_projects_mcp(session):
|
||||
|
||||
# Skip if server already exists for this starter projects folder
|
||||
if validation_result.should_skip:
|
||||
# Check if the URL needs updating (e.g., server port changed at restart)
|
||||
expected_url = await get_project_streamable_http_url(user_starter_folder.id)
|
||||
existing_config = validation_result.existing_config or {}
|
||||
existing_args = existing_config.get("args", [])
|
||||
existing_urls = await extract_urls_from_strings(existing_args)
|
||||
|
||||
if any(expected_url == url for url in existing_urls):
|
||||
await logger.adebug(
|
||||
f"MCP server '{validation_result.server_name}' already exists and is correctly "
|
||||
f"configured for user {user.username}'s starter projects (project ID: "
|
||||
f"{user_starter_folder.id}), skipping"
|
||||
)
|
||||
continue # Skip this user since server already exists for the same project
|
||||
|
||||
# URL has changed (e.g., server restarted on a different port), fall through to update
|
||||
await logger.adebug(
|
||||
f"MCP server '{validation_result.server_name}' already exists for user "
|
||||
f"{user.username}'s starter projects (project ID: "
|
||||
f"{user_starter_folder.id}), skipping"
|
||||
f"MCP server '{validation_result.server_name}' exists for user {user.username}'s "
|
||||
f"starter projects but URL has changed (was: {existing_urls}, now: {expected_url}), updating"
|
||||
)
|
||||
continue # Skip this user since server already exists for the same project
|
||||
|
||||
server_name = validation_result.server_name
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import chromadb.errors
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from lfx.base.data.utils import extract_text_from_bytes
|
||||
from lfx.log import logger
|
||||
|
||||
from langflow.api.utils import CurrentActiveUser
|
||||
@@ -170,7 +171,7 @@ async def preview_chunks(
|
||||
try:
|
||||
file_content = await uploaded_file.read()
|
||||
file_name = uploaded_file.filename or "unknown"
|
||||
text_content = file_content.decode("utf-8", errors="ignore")
|
||||
text_content = extract_text_from_bytes(file_name, file_content)
|
||||
|
||||
if not text_content.strip():
|
||||
file_previews.append(
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__: list[str] = list(_lfx_all)
|
||||
# Register redirected submodules in sys.modules for direct importlib.import_module() calls
|
||||
# This allows imports like: import langflow.components.knowledge_bases.ingestion
|
||||
_redirected_submodules = {
|
||||
"langflow.components.knowledge_bases.ingestion": "lfx.components.files_and_knowledge.ingestion",
|
||||
# "langflow.components.knowledge_bases.ingestion": "lfx.components.files_and_knowledge.ingestion",
|
||||
"langflow.components.knowledge_bases.retrieval": "lfx.components.files_and_knowledge.retrieval",
|
||||
}
|
||||
|
||||
@@ -52,12 +52,6 @@ for old_path, new_path in _redirected_submodules.items():
|
||||
def __getattr__(attr_name: str) -> Any:
|
||||
"""Forward attribute access to lfx.components.files_and_knowledge."""
|
||||
# Handle submodule access for backwards compatibility
|
||||
if attr_name == "ingestion":
|
||||
from importlib import import_module
|
||||
|
||||
result = import_module("lfx.components.files_and_knowledge.ingestion")
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
if attr_name == "retrieval":
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ def json_schema_from_flow(flow: Flow) -> dict:
|
||||
template = node_data["template"]
|
||||
|
||||
for field_name, field_data in template.items():
|
||||
if field_data != "Component" and field_data.get("show", False) and not field_data.get("advanced", False):
|
||||
if isinstance(field_data, dict) and field_data.get("show", False) and not field_data.get("advanced", False):
|
||||
field_type = field_data.get("type", "string")
|
||||
properties[field_name] = {
|
||||
"type": field_type,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,10 +2,12 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlmodel import Column, DateTime, Field, Relationship, SQLModel, func
|
||||
|
||||
from langflow.schema.serialize import UUIDstr
|
||||
from langflow.services.database.utils import validate_non_empty_string, validate_non_empty_string_optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -25,11 +27,18 @@ class Deployment(SQLModel, table=True): # type: ignore[call-arg]
|
||||
|
||||
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
|
||||
resource_key: str = Field(index=True)
|
||||
user_id: UUID = Field(foreign_key="user.id", index=True)
|
||||
user_id: UUIDstr = Field(
|
||||
sa_column=Column(sa.Uuid(), ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
)
|
||||
# "project" is represented by a Folder row in the existing schema.
|
||||
project_id: UUID = Field(foreign_key="folder.id", index=True)
|
||||
# CASCADE behaviour is enforced at the migration/DDL level.
|
||||
deployment_provider_account_id: UUID = Field(foreign_key="deployment_provider_account.id", index=True)
|
||||
project_id: UUIDstr = Field(
|
||||
sa_column=Column(sa.Uuid(), ForeignKey("folder.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
)
|
||||
deployment_provider_account_id: UUIDstr = Field(
|
||||
sa_column=Column(
|
||||
sa.Uuid(), ForeignKey("deployment_provider_account.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
name: str = Field(index=True)
|
||||
created_at: datetime | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -2,10 +2,12 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlmodel import Column, DateTime, Field, Relationship, SQLModel, func
|
||||
|
||||
from langflow.schema.serialize import UUIDstr
|
||||
from langflow.services.database.utils import (
|
||||
normalize_string_or_none,
|
||||
validate_non_empty_string,
|
||||
@@ -29,7 +31,9 @@ class DeploymentProviderAccount(SQLModel, table=True): # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
|
||||
user_id: UUID = Field(foreign_key="user.id", index=True)
|
||||
user_id: UUIDstr = Field(
|
||||
sa_column=Column(sa.Uuid(), ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
)
|
||||
# provider_tenant_id participates in a unique constraint. When NULL,
|
||||
# SQL-standard databases (PostgreSQL, SQLite) treat NULL != NULL in unique
|
||||
# constraints, so multiple rows with the same (user_id, provider_url) are
|
||||
|
||||
@@ -6,7 +6,6 @@ from uuid import UUID, uuid4
|
||||
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
|
||||
from pydantic import Field as PydanticField
|
||||
from pydantic.alias_generators import to_camel
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlmodel import JSON, Column, Field, Relationship, SQLModel, Text
|
||||
|
||||
from langflow.serialization.serialization import serialize
|
||||
@@ -70,7 +69,9 @@ class TraceBase(SQLModel):
|
||||
total_latency_ms: int = Field(default=0, description="Total execution time in milliseconds")
|
||||
total_tokens: int = Field(default=0, description="Total tokens used across all LLM calls")
|
||||
flow_id: UUID = Field(
|
||||
sa_column=Column(ForeignKey("flow.id", ondelete="CASCADE"), index=True, nullable=False),
|
||||
foreign_key="flow.id",
|
||||
ondelete="CASCADE",
|
||||
index=True,
|
||||
description="ID of the flow this trace belongs to",
|
||||
)
|
||||
session_id: str | None = Field(
|
||||
|
||||
@@ -107,6 +107,8 @@ def span_to_response(span: SpanTable) -> SpanReadResponse:
|
||||
"completionTokens": safe_int_tokens(output_tokens),
|
||||
"totalTokens": total_tokens,
|
||||
}
|
||||
inputs = span.inputs if isinstance(span.inputs, dict) or span.inputs is None else {"input": span.inputs}
|
||||
outputs = span.outputs if isinstance(span.outputs, dict) or span.outputs is None else {"output": span.outputs}
|
||||
|
||||
return SpanReadResponse(
|
||||
id=span.id,
|
||||
@@ -116,8 +118,8 @@ def span_to_response(span: SpanTable) -> SpanReadResponse:
|
||||
start_time=span.start_time,
|
||||
end_time=span.end_time,
|
||||
latency_ms=span.latency_ms,
|
||||
inputs=span.inputs,
|
||||
outputs=span.outputs,
|
||||
inputs=inputs,
|
||||
outputs=outputs,
|
||||
error=span.error,
|
||||
model_name=(span.attributes or {}).get("gen_ai.response.model"),
|
||||
token_usage=token_usage,
|
||||
|
||||
@@ -373,11 +373,18 @@ class NativeTracer(BaseTracer):
|
||||
return None
|
||||
|
||||
from langflow.services.tracing.native_callback import NativeCallbackHandler
|
||||
from langflow.services.tracing.service import component_context_var
|
||||
|
||||
# LangChain spans must be linked to the component that triggered them so the
|
||||
# trace tree reflects the actual execution hierarchy.
|
||||
# Component context is set before add_trace() is called,
|
||||
# so it's available when components call get_langchain_callbacks() during flow execution.
|
||||
# We need to check component_context in case _current_component_id was still None when callbacks were created.
|
||||
parent_span_id = None
|
||||
if self._current_component_id:
|
||||
component_context = component_context_var.get(None)
|
||||
if component_context:
|
||||
component_id = component_context.trace_id
|
||||
parent_span_id = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{component_id}")
|
||||
elif self._current_component_id:
|
||||
# Fallback for edge cases where component context might not be set
|
||||
parent_span_id = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{self._current_component_id}")
|
||||
|
||||
return NativeCallbackHandler(self, parent_span_id=parent_span_id)
|
||||
|
||||
@@ -50,7 +50,7 @@ class NativeCallbackHandler(BaseCallbackHandler):
|
||||
|
||||
def _resolve_parent_span_id(self, parent_run_id: UUID | None) -> UUID | None:
|
||||
"""Return the correct parent span ID so nested LangChain calls form a proper tree."""
|
||||
if parent_run_id:
|
||||
if parent_run_id and parent_run_id in self._spans:
|
||||
return self._get_span_id(parent_run_id)
|
||||
return self.parent_span_id
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@ import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langflow.base.knowledge_bases.knowledge_base_utils import get_knowledge_bases
|
||||
from langflow.schema.data import Data
|
||||
from langflow.schema.dataframe import DataFrame
|
||||
from lfx.components.knowledge_bases.ingestion import KnowledgeIngestionComponent
|
||||
from langflow.schema.message import Message
|
||||
from lfx.base.knowledge_bases import get_knowledge_bases
|
||||
from lfx.components.deactivated.ingestion import KnowledgeIngestionComponent
|
||||
|
||||
from tests.base import ComponentTestBaseWithClient
|
||||
|
||||
pytestmark = pytest.mark.skip(reason="KnowledgeIngestionComponent is deactivated")
|
||||
|
||||
|
||||
class TestKnowledgeIngestionComponent(ComponentTestBaseWithClient):
|
||||
@pytest.fixture
|
||||
@@ -342,6 +345,32 @@ class TestKnowledgeIngestionComponent(ComponentTestBaseWithClient):
|
||||
assert result["knowledge_base"]["value"] == "new_test_kb"
|
||||
assert "new_test_kb" in result["knowledge_base"]["options"]
|
||||
|
||||
@patch("langflow.components.knowledge_bases.ingestion.json.loads")
|
||||
@patch("langflow.components.knowledge_bases.ingestion.decrypt_api_key")
|
||||
async def test_build_kb_info_with_message_input(
|
||||
self, mock_decrypt, mock_json_loads, component_class, default_kwargs
|
||||
):
|
||||
"""Test that Message input is accepted and converted to DataFrame."""
|
||||
# Replace the DataFrame input with a Message
|
||||
default_kwargs["input_df"] = Message(text="Sample text 1")
|
||||
default_kwargs["column_config"] = [
|
||||
{"column_name": "text", "vectorize": True, "identifier": True},
|
||||
]
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_json_loads.return_value = {
|
||||
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"api_key": "encrypted_key", # pragma:allowlist secret
|
||||
}
|
||||
mock_decrypt.return_value = "decrypted_key"
|
||||
|
||||
with patch.object(component, "_create_vector_store"), patch.object(component, "_save_kb_files"):
|
||||
result = await component.build_kb_info()
|
||||
|
||||
assert isinstance(result, Data)
|
||||
assert result.data["rows"] == 1
|
||||
assert result.data["kb_name"] == "test_kb"
|
||||
|
||||
async def test_update_build_config_invalid_kb_name(self, component_class, default_kwargs):
|
||||
"""Test updating build config with invalid KB name."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import contextlib
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langflow.base.knowledge_bases.knowledge_base_utils import get_knowledge_bases
|
||||
from lfx.components.knowledge_bases.retrieval import KnowledgeRetrievalComponent
|
||||
from pydantic import SecretStr
|
||||
from lfx.base.knowledge_bases.knowledge_base_utils import get_knowledge_bases
|
||||
from lfx.components.files_and_knowledge.retrieval import KnowledgeBaseComponent
|
||||
|
||||
from tests.base import ComponentTestBaseWithClient
|
||||
|
||||
|
||||
class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
|
||||
@pytest.fixture
|
||||
def component_class(self):
|
||||
"""Return the component class to test."""
|
||||
return KnowledgeRetrievalComponent
|
||||
return KnowledgeBaseComponent
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_knowledge_base_path(self, tmp_path):
|
||||
@@ -184,14 +183,13 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": "test-api-key", # pragma:allowlist secret
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata)
|
||||
result = component._build_embeddings(metadata, api_key="test-api-key")
|
||||
|
||||
mock_openai_embeddings.assert_called_once_with(
|
||||
model="text-embedding-ada-002",
|
||||
@@ -207,7 +205,6 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": None,
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
@@ -222,14 +219,13 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
metadata = {
|
||||
"embedding_provider": "Cohere",
|
||||
"embedding_model": "embed-english-v3.0",
|
||||
"api_key": "test-api-key", # pragma:allowlist secret
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_cohere_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata)
|
||||
result = component._build_embeddings(metadata, api_key="test-api-key")
|
||||
|
||||
mock_cohere_embeddings.assert_called_once_with(
|
||||
model="embed-english-v3.0",
|
||||
@@ -244,7 +240,6 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
metadata = {
|
||||
"embedding_provider": "Cohere",
|
||||
"embedding_model": "embed-english-v3.0",
|
||||
"api_key": None,
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
@@ -258,8 +253,7 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
metadata = {
|
||||
"embedding_provider": "Custom",
|
||||
"embedding_model": "custom-model",
|
||||
"api_key": "test-key", # pragma:allowlist secret
|
||||
} # pragma:allowlist secret
|
||||
}
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Custom embedding models not yet supported"):
|
||||
component._build_embeddings(metadata)
|
||||
@@ -277,18 +271,143 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
with pytest.raises(NotImplementedError, match="Embedding provider 'UnsupportedProvider' is not supported"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
def test_build_embeddings_with_user_api_key(self, component_class, default_kwargs):
|
||||
"""Test that user-provided API key overrides stored one."""
|
||||
# Use a real SecretStr object instead of a mock
|
||||
mock_secret = SecretStr("user-provided-key")
|
||||
@patch("langchain_google_genai.GoogleGenerativeAIEmbeddings")
|
||||
def test_build_embeddings_google_generative_ai(self, mock_google_embeddings, component_class, default_kwargs):
|
||||
"""Test building Google Generative AI embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
default_kwargs["api_key"] = mock_secret
|
||||
metadata = {
|
||||
"embedding_provider": "Google Generative AI",
|
||||
"embedding_model": "models/embedding-001",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_google_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata, api_key="test-google-key")
|
||||
|
||||
mock_google_embeddings.assert_called_once_with(
|
||||
model="models/embedding-001",
|
||||
google_api_key="test-google-key", # pragma:allowlist secret
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_google_no_key(self, component_class, default_kwargs):
|
||||
"""Test building Google Generative AI embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Google Generative AI",
|
||||
"embedding_model": "models/embedding-001",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Google API key is required"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
@patch("langchain_ollama.OllamaEmbeddings")
|
||||
def test_build_embeddings_ollama(self, mock_ollama_embeddings, component_class, default_kwargs):
|
||||
"""Test building Ollama embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Ollama",
|
||||
"embedding_model": "nomic-embed-text",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_ollama_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(
|
||||
metadata,
|
||||
provider_vars={"OLLAMA_BASE_URL": "http://localhost:11434"},
|
||||
)
|
||||
|
||||
mock_ollama_embeddings.assert_called_once_with(
|
||||
model="nomic-embed-text",
|
||||
base_url="http://localhost:11434",
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_ibm.WatsonxEmbeddings")
|
||||
def test_build_embeddings_watsonx(self, mock_watsonx_embeddings, component_class, default_kwargs):
|
||||
"""Test building IBM WatsonX embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "IBM WatsonX",
|
||||
"embedding_model": "ibm/slate-125m-english-rtrvr-v2",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_watsonx_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(
|
||||
metadata,
|
||||
api_key="test-watsonx-key",
|
||||
provider_vars={
|
||||
"WATSONX_APIKEY": "test-watsonx-key", # pragma:allowlist secret
|
||||
"WATSONX_PROJECT_ID": "test-project-id",
|
||||
"WATSONX_URL": "https://us-south.ml.cloud.ibm.com",
|
||||
},
|
||||
)
|
||||
|
||||
mock_watsonx_embeddings.assert_called_once_with(
|
||||
model_id="ibm/slate-125m-english-rtrvr-v2",
|
||||
apikey="test-watsonx-key", # pragma:allowlist secret
|
||||
project_id="test-project-id",
|
||||
url="https://us-south.ml.cloud.ibm.com",
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_watsonx_no_key(self, component_class, default_kwargs):
|
||||
"""Test building IBM WatsonX embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "IBM WatsonX",
|
||||
"embedding_model": "ibm/slate-125m-english-rtrvr-v2",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="IBM WatsonX API key is required"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
@patch("langchain_openai.OpenAIEmbeddings")
|
||||
async def test_resolve_api_key_global_fallback(self, mock_openai_embeddings, component_class, default_kwargs):
|
||||
"""Test that retrieve_data resolves the global API key for OpenAI."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai_embeddings.return_value = mock_embeddings
|
||||
|
||||
# The async _resolve_api_key should find the global key
|
||||
with patch.object(component, "_resolve_api_key", return_value="global-openai-key"):
|
||||
result = component._build_embeddings(metadata, api_key="global-openai-key")
|
||||
|
||||
mock_openai_embeddings.assert_called_once_with(
|
||||
model="text-embedding-ada-002",
|
||||
api_key="global-openai-key", # pragma:allowlist secret
|
||||
chunk_size=1000,
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_with_explicit_api_key(self, component_class, default_kwargs):
|
||||
"""Test that an explicit API key is used when passed."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": "stored-key", # pragma:allowlist secret
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
@@ -296,9 +415,8 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai.return_value = mock_embeddings
|
||||
|
||||
component._build_embeddings(metadata)
|
||||
component._build_embeddings(metadata, api_key="user-provided-key")
|
||||
|
||||
# The user-provided key should override the stored key in metadata
|
||||
mock_openai.assert_called_once_with(
|
||||
model="text-embedding-ada-002",
|
||||
api_key="user-provided-key", # pragma:allowlist secret
|
||||
@@ -337,29 +455,49 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
assert hasattr(component, "top_k")
|
||||
assert hasattr(component, "include_embeddings")
|
||||
|
||||
async def test_retrieve_data_method_exists(self, component_class, default_kwargs):
|
||||
async def test_retrieve_data_method_exists(self, component_class, default_kwargs, active_user):
|
||||
"""Test that retrieve_data method exists and can be called."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Just verify the method exists and has the right signature
|
||||
assert hasattr(component, "retrieve_data"), "Component should have retrieve_data method"
|
||||
|
||||
# Build a mock Chroma that returns results in the expected format
|
||||
mock_doc = MagicMock()
|
||||
mock_doc.page_content = "test content"
|
||||
mock_doc.metadata = {"_id": "doc1", "source": "test"}
|
||||
|
||||
mock_chroma_instance = MagicMock()
|
||||
mock_chroma_instance.similarity_search.return_value = [mock_doc]
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = active_user.username
|
||||
|
||||
# Mock all external calls to avoid integration issues
|
||||
with (
|
||||
patch.object(component, "_get_kb_metadata") as mock_get_metadata,
|
||||
patch.object(component, "_build_embeddings") as mock_build_embeddings,
|
||||
patch("langchain_chroma.Chroma"),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch("lfx.components.files_and_knowledge.retrieval.get_user_by_id", return_value=mock_user),
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval._get_knowledge_bases_root_path",
|
||||
return_value=Path(default_kwargs["kb_root_path"]),
|
||||
),
|
||||
patch("chromadb.api.client.SharedSystemClient.clear_system_cache"),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.Chroma", return_value=mock_chroma_instance),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_get_metadata.return_value = {"embedding_provider": "HuggingFace", "embedding_model": "test-model"}
|
||||
mock_build_embeddings.return_value = MagicMock()
|
||||
|
||||
# This is a unit test focused on the component's internal logic
|
||||
with contextlib.suppress(Exception):
|
||||
await component.retrieve_data()
|
||||
result = await component.retrieve_data()
|
||||
|
||||
# Verify internal methods were called
|
||||
mock_get_metadata.assert_called_once()
|
||||
mock_build_embeddings.assert_called_once()
|
||||
assert len(result) == 1
|
||||
|
||||
def test_include_embeddings_parameter(self, component_class, default_kwargs):
|
||||
"""Test that include_embeddings parameter is properly set."""
|
||||
@@ -372,3 +510,428 @@ class TestKnowledgeRetrievalComponent(ComponentTestBaseWithClient):
|
||||
default_kwargs["include_embeddings"] = False
|
||||
component = component_class(**default_kwargs)
|
||||
assert component.include_embeddings is False
|
||||
|
||||
# --- _resolve_provider_variables tests ---
|
||||
|
||||
async def test_resolve_provider_variables_empty_provider_vars(self, component_class, default_kwargs):
|
||||
"""Test _resolve_provider_variables when provider has no variables defined."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_provider_all_variables",
|
||||
return_value=[],
|
||||
):
|
||||
result = await component._resolve_provider_variables("Ollama")
|
||||
|
||||
assert result == {}
|
||||
|
||||
async def test_resolve_provider_variables_variable_service_returns_none(self, component_class, default_kwargs):
|
||||
"""Test _resolve_provider_variables when variable_service is None."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_provider_all_variables",
|
||||
return_value=[{"variable_key": "OLLAMA_BASE_URL"}],
|
||||
),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_variable_service",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await component._resolve_provider_variables("Ollama")
|
||||
|
||||
assert result == {}
|
||||
|
||||
async def test_resolve_provider_variables_user_id_is_none(self, component_class, default_kwargs):
|
||||
"""Test _resolve_provider_variables when user_id is None."""
|
||||
default_kwargs["_user_id"] = None
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Set a mock vertex so user_id property returns None
|
||||
# instead of the string "None" from PlaceholderGraph
|
||||
mock_vertex = MagicMock()
|
||||
mock_vertex.graph.user_id = None
|
||||
component._vertex = mock_vertex
|
||||
|
||||
with patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_provider_all_variables",
|
||||
return_value=[{"variable_key": "OLLAMA_BASE_URL"}],
|
||||
):
|
||||
result = await component._resolve_provider_variables("Ollama")
|
||||
|
||||
assert result == {}
|
||||
|
||||
async def test_resolve_provider_variables_user_id_as_string(self, component_class, default_kwargs):
|
||||
"""Test _resolve_provider_variables when user_id is a string UUID."""
|
||||
user_uuid = uuid.uuid4()
|
||||
default_kwargs["_user_id"] = str(user_uuid)
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_variable_service = AsyncMock()
|
||||
mock_variable_service.get_variable = AsyncMock(return_value="http://localhost:11434")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_provider_all_variables",
|
||||
return_value=[{"variable_key": "OLLAMA_BASE_URL"}],
|
||||
),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_variable_service",
|
||||
return_value=mock_variable_service,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await component._resolve_provider_variables("Ollama")
|
||||
|
||||
assert result == {"OLLAMA_BASE_URL": "http://localhost:11434"}
|
||||
# Verify the user_id was correctly converted to UUID
|
||||
call_kwargs = mock_variable_service.get_variable.call_args[1]
|
||||
assert call_kwargs["user_id"] == user_uuid
|
||||
|
||||
async def test_resolve_provider_variables_lookup_falls_back_to_env(
|
||||
self, component_class, default_kwargs, monkeypatch
|
||||
):
|
||||
"""Test _resolve_provider_variables falls back to env var on service error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_variable_service = AsyncMock()
|
||||
mock_variable_service.get_variable = AsyncMock(side_effect=ValueError("Not found"))
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-fallback:11434")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_provider_all_variables",
|
||||
return_value=[{"variable_key": "OLLAMA_BASE_URL"}],
|
||||
),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_variable_service",
|
||||
return_value=mock_variable_service,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await component._resolve_provider_variables("Ollama")
|
||||
|
||||
assert result == {"OLLAMA_BASE_URL": "http://env-fallback:11434"}
|
||||
|
||||
# --- _resolve_api_key tests ---
|
||||
|
||||
async def test_resolve_api_key_unknown_provider(self, component_class, default_kwargs):
|
||||
"""Test _resolve_api_key when provider is not in provider_variable_map."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_model_provider_variable_mapping",
|
||||
return_value={},
|
||||
):
|
||||
result = await component._resolve_api_key("UnknownProvider")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_resolve_api_key_user_id_is_none(self, component_class, default_kwargs):
|
||||
"""Test _resolve_api_key when user_id is None."""
|
||||
default_kwargs["_user_id"] = None
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Set a mock vertex so user_id property returns None
|
||||
# instead of the string "None" from PlaceholderGraph
|
||||
mock_vertex = MagicMock()
|
||||
mock_vertex.graph.user_id = None
|
||||
component._vertex = mock_vertex
|
||||
|
||||
with patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_model_provider_variable_mapping",
|
||||
return_value={"OpenAI": "OPENAI_API_KEY"},
|
||||
):
|
||||
result = await component._resolve_api_key("OpenAI")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_resolve_api_key_variable_service_is_none(self, component_class, default_kwargs):
|
||||
"""Test _resolve_api_key when variable_service returns None."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_model_provider_variable_mapping",
|
||||
return_value={"OpenAI": "OPENAI_API_KEY"},
|
||||
),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_variable_service",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await component._resolve_api_key("OpenAI")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_resolve_api_key_variable_service_raises(self, component_class, default_kwargs):
|
||||
"""Test _resolve_api_key returns None when variable_service.get_variable raises."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_variable_service = AsyncMock()
|
||||
mock_variable_service.get_variable = AsyncMock(side_effect=ValueError("Not found"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_model_provider_variable_mapping",
|
||||
return_value={"OpenAI": "OPENAI_API_KEY"},
|
||||
),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_variable_service",
|
||||
return_value=mock_variable_service,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await component._resolve_api_key("OpenAI")
|
||||
|
||||
assert result is None
|
||||
|
||||
# --- _build_embeddings edge case tests ---
|
||||
|
||||
@patch("langchain_ollama.OllamaEmbeddings")
|
||||
def test_build_embeddings_ollama_without_base_url(self, mock_ollama_embeddings, component_class, default_kwargs):
|
||||
"""Test building Ollama embeddings without base_url (empty provider_vars)."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Ollama",
|
||||
"embedding_model": "nomic-embed-text",
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_ollama_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata, provider_vars={})
|
||||
|
||||
# Should be called without base_url kwarg
|
||||
mock_ollama_embeddings.assert_called_once_with(model="nomic-embed-text")
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_ollama.OllamaEmbeddings")
|
||||
def test_build_embeddings_ollama_no_provider_vars(self, mock_ollama_embeddings, component_class, default_kwargs):
|
||||
"""Test building Ollama embeddings with provider_vars=None."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Ollama",
|
||||
"embedding_model": "nomic-embed-text",
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_ollama_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata, provider_vars=None)
|
||||
|
||||
mock_ollama_embeddings.assert_called_once_with(model="nomic-embed-text")
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_ibm.WatsonxEmbeddings")
|
||||
def test_build_embeddings_watsonx_api_key_from_provider_vars(
|
||||
self, mock_watsonx_embeddings, component_class, default_kwargs
|
||||
):
|
||||
"""Test WatsonX uses api_key from provider_vars fallback when api_key param is None."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "IBM WatsonX",
|
||||
"embedding_model": "ibm/slate-125m-english-rtrvr-v2",
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_watsonx_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(
|
||||
metadata,
|
||||
api_key=None,
|
||||
provider_vars={
|
||||
"WATSONX_APIKEY": "vars-watsonx-key", # pragma:allowlist secret
|
||||
"WATSONX_PROJECT_ID": "project-123",
|
||||
"WATSONX_URL": "https://us-south.ml.cloud.ibm.com",
|
||||
},
|
||||
)
|
||||
|
||||
mock_watsonx_embeddings.assert_called_once_with(
|
||||
model_id="ibm/slate-125m-english-rtrvr-v2",
|
||||
apikey="vars-watsonx-key", # pragma:allowlist secret
|
||||
project_id="project-123",
|
||||
url="https://us-south.ml.cloud.ibm.com",
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_ibm.WatsonxEmbeddings")
|
||||
def test_build_embeddings_watsonx_partial_vars(self, mock_watsonx_embeddings, component_class, default_kwargs):
|
||||
"""Test WatsonX with only apikey, no project_id or url."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "IBM WatsonX",
|
||||
"embedding_model": "ibm/slate-125m-english-rtrvr-v2",
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_watsonx_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(
|
||||
metadata,
|
||||
api_key="only-api-key",
|
||||
provider_vars={},
|
||||
)
|
||||
|
||||
# project_id and url should be omitted from kwargs
|
||||
mock_watsonx_embeddings.assert_called_once_with(
|
||||
model_id="ibm/slate-125m-english-rtrvr-v2",
|
||||
apikey="only-api-key", # pragma:allowlist secret
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_empty_metadata(self, component_class, default_kwargs):
|
||||
"""Test _build_embeddings with empty metadata dict (no provider, no model)."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Embedding provider 'None' is not supported"):
|
||||
component._build_embeddings({})
|
||||
|
||||
# --- retrieve_data integration edge case tests ---
|
||||
|
||||
async def test_retrieve_data_user_id_is_none(self, component_class, default_kwargs):
|
||||
"""Test retrieve_data raises when user_id is None."""
|
||||
default_kwargs["_user_id"] = None
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Set a mock vertex so user_id property returns None
|
||||
# instead of the string "None" from PlaceholderGraph
|
||||
mock_vertex = MagicMock()
|
||||
mock_vertex.graph.user_id = None
|
||||
component._vertex = mock_vertex
|
||||
|
||||
with (
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(ValueError, match="User ID is required"):
|
||||
await component.retrieve_data()
|
||||
|
||||
async def test_retrieve_data_user_not_found(self, component_class, default_kwargs):
|
||||
"""Test retrieve_data raises when user is not found in DB."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with (
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval.get_user_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(ValueError, match=r"User with ID .* not found"):
|
||||
await component.retrieve_data()
|
||||
|
||||
async def test_retrieve_data_with_search_query(self, component_class, default_kwargs, active_user):
|
||||
"""Test retrieve_data with a populated search_query uses similarity_search_with_score."""
|
||||
default_kwargs["search_query"] = "find me something"
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_doc = MagicMock()
|
||||
mock_doc.page_content = "matched content"
|
||||
mock_doc.metadata = {"_id": "doc1", "source": "test"}
|
||||
|
||||
mock_chroma_instance = MagicMock()
|
||||
# similarity_search_with_score returns (doc, score) tuples
|
||||
mock_chroma_instance.similarity_search_with_score.return_value = [(mock_doc, 0.85)]
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = active_user.username
|
||||
|
||||
with (
|
||||
patch.object(component, "_get_kb_metadata") as mock_get_metadata,
|
||||
patch.object(component, "_build_embeddings") as mock_build_embeddings,
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch("lfx.components.files_and_knowledge.retrieval.get_user_by_id", return_value=mock_user),
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval._get_knowledge_bases_root_path",
|
||||
return_value=Path(default_kwargs["kb_root_path"]),
|
||||
),
|
||||
patch("chromadb.api.client.SharedSystemClient.clear_system_cache"),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.Chroma", return_value=mock_chroma_instance),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_get_metadata.return_value = {"embedding_provider": "HuggingFace", "embedding_model": "test-model"}
|
||||
mock_build_embeddings.return_value = MagicMock()
|
||||
|
||||
result = await component.retrieve_data()
|
||||
|
||||
# Verify similarity_search_with_score was used (not similarity_search)
|
||||
mock_chroma_instance.similarity_search_with_score.assert_called_once_with(query="find me something", k=5)
|
||||
mock_chroma_instance.similarity_search.assert_not_called()
|
||||
assert len(result) == 1
|
||||
|
||||
async def test_retrieve_data_without_search_query(self, component_class, default_kwargs, active_user):
|
||||
"""Test retrieve_data with empty search_query uses similarity_search."""
|
||||
default_kwargs["search_query"] = ""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_doc = MagicMock()
|
||||
mock_doc.page_content = "all content"
|
||||
mock_doc.metadata = {"_id": "doc1", "source": "test"}
|
||||
|
||||
mock_chroma_instance = MagicMock()
|
||||
mock_chroma_instance.similarity_search.return_value = [mock_doc]
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = active_user.username
|
||||
|
||||
with (
|
||||
patch.object(component, "_get_kb_metadata") as mock_get_metadata,
|
||||
patch.object(component, "_build_embeddings") as mock_build_embeddings,
|
||||
patch("lfx.components.files_and_knowledge.retrieval.session_scope") as mock_session_scope,
|
||||
patch("lfx.components.files_and_knowledge.retrieval.get_user_by_id", return_value=mock_user),
|
||||
patch(
|
||||
"lfx.components.files_and_knowledge.retrieval._get_knowledge_bases_root_path",
|
||||
return_value=Path(default_kwargs["kb_root_path"]),
|
||||
),
|
||||
patch("chromadb.api.client.SharedSystemClient.clear_system_cache"),
|
||||
patch("lfx.components.files_and_knowledge.retrieval.Chroma", return_value=mock_chroma_instance),
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session_scope.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_scope.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_get_metadata.return_value = {"embedding_provider": "HuggingFace", "embedding_model": "test-model"}
|
||||
mock_build_embeddings.return_value = MagicMock()
|
||||
|
||||
result = await component.retrieve_data()
|
||||
|
||||
# Verify similarity_search was used (not similarity_search_with_score)
|
||||
mock_chroma_instance.similarity_search.assert_called_once_with(query="", k=5)
|
||||
mock_chroma_instance.similarity_search_with_score.assert_not_called()
|
||||
assert len(result) == 1
|
||||
|
||||
@@ -469,8 +469,8 @@ class TestAgentComponent(ComponentTestBaseWithoutClient):
|
||||
# Verify WatsonX fields are now shown
|
||||
assert updated_config["base_url_ibm_watsonx"]["show"] is True
|
||||
assert updated_config["project_id"]["show"] is True
|
||||
assert updated_config["base_url_ibm_watsonx"]["required"] is True
|
||||
assert updated_config["project_id"]["required"] is True
|
||||
assert updated_config["base_url_ibm_watsonx"]["required"] is False
|
||||
assert updated_config["project_id"]["required"] is False
|
||||
|
||||
async def test_update_build_config_hides_watsonx_fields_for_other_providers(self, component_class, default_kwargs):
|
||||
"""Test that update_build_config hides WatsonX fields when other providers are selected."""
|
||||
|
||||
175
src/backend/tests/unit/test_extract_text_from_bytes.py
Normal file
175
src/backend/tests/unit/test_extract_text_from_bytes.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from lfx.base.data.utils import extract_text_from_bytes
|
||||
from pypdf import PdfWriter
|
||||
|
||||
|
||||
def _make_blank_pdf(num_pages: int = 1) -> bytes:
|
||||
"""Create a valid PDF with blank pages."""
|
||||
writer = PdfWriter()
|
||||
for _ in range(num_pages):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
buf = BytesIO()
|
||||
writer.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _mock_pdf_reader(pages_text: list[str]):
|
||||
"""Create a mock PdfReader that returns pages with given text."""
|
||||
mock_reader = MagicMock()
|
||||
mock_pages = []
|
||||
for text in pages_text:
|
||||
page = MagicMock()
|
||||
page.extract_text.return_value = text
|
||||
mock_pages.append(page)
|
||||
mock_reader.pages = mock_pages
|
||||
mock_reader.__enter__ = MagicMock(return_value=mock_reader)
|
||||
mock_reader.__exit__ = MagicMock(return_value=False)
|
||||
return mock_reader
|
||||
|
||||
|
||||
class TestExtractTextFromBytesPDF:
|
||||
@patch("lfx.base.data.utils.PdfReader")
|
||||
def test_should_extract_text_from_valid_pdf(self, mock_reader_cls):
|
||||
mock_reader_cls.return_value = _mock_pdf_reader(["Hello World"])
|
||||
result = extract_text_from_bytes("document.pdf", _make_blank_pdf())
|
||||
assert "Hello World" in result
|
||||
|
||||
@patch("lfx.base.data.utils.PdfReader")
|
||||
def test_should_extract_text_from_multi_page_pdf(self, mock_reader_cls):
|
||||
mock_reader_cls.return_value = _mock_pdf_reader(["Page one content", "Page two content"])
|
||||
result = extract_text_from_bytes("multi.pdf", _make_blank_pdf(2))
|
||||
assert "Page one content" in result
|
||||
assert "Page two content" in result
|
||||
|
||||
@patch("lfx.base.data.utils.PdfReader")
|
||||
def test_should_join_pages_with_double_newline(self, mock_reader_cls):
|
||||
mock_reader_cls.return_value = _mock_pdf_reader(["First", "Second"])
|
||||
result = extract_text_from_bytes("test.pdf", _make_blank_pdf(2))
|
||||
assert result == "First\n\nSecond"
|
||||
|
||||
@patch("lfx.base.data.utils.PdfReader")
|
||||
def test_should_be_case_insensitive_on_extension(self, mock_reader_cls):
|
||||
mock_reader_cls.return_value = _mock_pdf_reader(["Test"])
|
||||
result = extract_text_from_bytes("DOC.PDF", _make_blank_pdf())
|
||||
assert "Test" in result
|
||||
|
||||
def test_should_raise_value_error_for_corrupted_pdf(self):
|
||||
with pytest.raises(ValueError, match="Failed to parse PDF file"):
|
||||
extract_text_from_bytes("bad.pdf", b"this is not a pdf")
|
||||
|
||||
def test_should_raise_value_error_for_empty_pdf_bytes(self):
|
||||
with pytest.raises(ValueError, match="Failed to parse PDF file"):
|
||||
extract_text_from_bytes("empty.pdf", b"")
|
||||
|
||||
def test_should_handle_pdf_with_blank_pages(self):
|
||||
result = extract_text_from_bytes("blank.pdf", _make_blank_pdf())
|
||||
assert isinstance(result, str)
|
||||
|
||||
@patch("lfx.base.data.utils.PdfReader")
|
||||
def test_should_handle_page_returning_none(self, mock_reader_cls):
|
||||
mock_reader_cls.return_value = _mock_pdf_reader(["Text"])
|
||||
mock_reader_cls.return_value.pages[0].extract_text.return_value = None
|
||||
mock_reader_cls.return_value.__enter__.return_value = mock_reader_cls.return_value
|
||||
result = extract_text_from_bytes("null_page.pdf", _make_blank_pdf())
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestExtractTextFromBytesDOCX:
|
||||
def test_should_extract_text_from_valid_docx(self):
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_paragraph("Hello from DOCX")
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
result = extract_text_from_bytes("file.docx", buf.getvalue())
|
||||
assert "Hello from DOCX" in result
|
||||
|
||||
def test_should_extract_multiple_paragraphs(self):
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_paragraph("First paragraph")
|
||||
doc.add_paragraph("Second paragraph")
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
result = extract_text_from_bytes("file.docx", buf.getvalue())
|
||||
assert "First paragraph" in result
|
||||
assert "Second paragraph" in result
|
||||
assert "\n\n" in result
|
||||
|
||||
def test_should_be_case_insensitive_on_extension(self):
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_paragraph("Case test")
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
result = extract_text_from_bytes("FILE.DOCX", buf.getvalue())
|
||||
assert "Case test" in result
|
||||
|
||||
def test_should_raise_value_error_for_corrupted_docx(self):
|
||||
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
|
||||
extract_text_from_bytes("bad.docx", b"not a valid docx")
|
||||
|
||||
def test_should_raise_value_error_for_empty_docx_bytes(self):
|
||||
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
|
||||
extract_text_from_bytes("empty.docx", b"")
|
||||
|
||||
def test_should_handle_docx_with_no_paragraphs(self):
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
result = extract_text_from_bytes("empty_doc.docx", buf.getvalue())
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestExtractTextFromBytesPlainText:
|
||||
def test_should_decode_utf8_text(self):
|
||||
content = b"Hello plain text"
|
||||
result = extract_text_from_bytes("readme.txt", content)
|
||||
assert result == "Hello plain text"
|
||||
|
||||
def test_should_handle_non_utf8_gracefully(self):
|
||||
content = b"\xff\xfe\x00\x01 some text"
|
||||
result = extract_text_from_bytes("binary.txt", content)
|
||||
assert isinstance(result, str)
|
||||
assert "some text" in result
|
||||
|
||||
def test_should_handle_empty_content(self):
|
||||
result = extract_text_from_bytes("empty.txt", b"")
|
||||
assert result == ""
|
||||
|
||||
def test_should_handle_csv_as_plain_text(self):
|
||||
content = b"col1,col2\nval1,val2"
|
||||
result = extract_text_from_bytes("data.csv", content)
|
||||
assert "col1,col2" in result
|
||||
|
||||
def test_should_handle_json_as_plain_text(self):
|
||||
content = b'{"key": "value"}'
|
||||
result = extract_text_from_bytes("data.json", content)
|
||||
assert '"key"' in result
|
||||
|
||||
def test_should_handle_unknown_extension_as_plain_text(self):
|
||||
content = b"some content"
|
||||
result = extract_text_from_bytes("file.xyz", content)
|
||||
assert result == "some content"
|
||||
|
||||
def test_should_handle_file_without_extension(self):
|
||||
content = b"no extension"
|
||||
result = extract_text_from_bytes("Makefile", content)
|
||||
assert result == "no extension"
|
||||
|
||||
def test_should_preserve_unicode_characters(self):
|
||||
content = "café résumé naïve".encode()
|
||||
result = extract_text_from_bytes("unicode.txt", content)
|
||||
assert result == "café résumé naïve"
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Panel, useStoreApi } from "@xyflow/react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ENABLE_INSPECTION_PANEL } from "@/customization/feature-flags";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import type { AllNodeType } from "@/types/flow";
|
||||
import CanvasControlsDropdown from "./CanvasControlsDropdown";
|
||||
@@ -21,12 +18,6 @@ const CanvasControls = ({
|
||||
const isFlowLocked = useFlowStore(
|
||||
useShallow((state) => state.currentFlow?.locked),
|
||||
);
|
||||
const inspectionPanelVisible = useFlowStore(
|
||||
(state) => state.inspectionPanelVisible,
|
||||
);
|
||||
const setInspectionPanelVisible = useFlowStore(
|
||||
(state) => state.setInspectionPanelVisible,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reactFlowStoreApi.setState({
|
||||
@@ -37,53 +28,23 @@ const CanvasControls = ({
|
||||
}, [isFlowLocked, reactFlowStoreApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
data-testid="main_canvas_controls"
|
||||
className="react-flow__controls !m-2 flex !flex-row rounded-md border border-border bg-background fill-foreground stroke-foreground text-primary [&>button]:border-0"
|
||||
position="bottom-left"
|
||||
>
|
||||
{children}
|
||||
{children && (
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
)}
|
||||
<CanvasControlsDropdown selectedNode={selectedNode} />
|
||||
<Panel
|
||||
data-testid="main_canvas_controls"
|
||||
className="react-flow__controls !m-2 flex !flex-row rounded-md border border-border bg-background fill-foreground stroke-foreground text-primary [&>button]:border-0"
|
||||
position="bottom-left"
|
||||
>
|
||||
{children}
|
||||
{children && (
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
<HelpDropdown />
|
||||
</Panel>
|
||||
{ENABLE_INSPECTION_PANEL && (
|
||||
<Panel
|
||||
data-testid="canvas_controls_inspector"
|
||||
className="react-flow__controls !left-auto !m-2 flex !flex-row rounded-md border border-border bg-background fill-foreground stroke-foreground text-primary [&>button]:border-0"
|
||||
position="bottom-right"
|
||||
>
|
||||
<Button
|
||||
unstyled
|
||||
size="icon"
|
||||
data-testid="canvas_controls_toggle_inspector"
|
||||
className={`group rounded-none px-2 py-2 flex items-center justify-center disabled:pointer-events-none disabled:opacity-50 ${inspectionPanelVisible ? "bg-accent" : "hover:bg-muted"}`}
|
||||
title={
|
||||
!selectedNode
|
||||
? "Select a node to open the Inspector Panel"
|
||||
: inspectionPanelVisible
|
||||
? "Hide Inspector Panel"
|
||||
: "Show Inspector Panel"
|
||||
}
|
||||
disabled={!selectedNode}
|
||||
onClick={() => setInspectionPanelVisible(!inspectionPanelVisible)}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name={inspectionPanelVisible ? "PanelRightClose" : "PanelRight"}
|
||||
className={`${inspectionPanelVisible ? "text-primary" : "text-muted-foreground group-hover:text-primary"} !h-5 !w-5`}
|
||||
/>
|
||||
</Button>
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
<CanvasControlsDropdown selectedNode={selectedNode} />
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
<HelpDropdown />
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { UPLOAD_ERROR_ALERT } from "@/constants/alerts_constants";
|
||||
import { useUpdateUser } from "@/controllers/API/queries/auth";
|
||||
import {
|
||||
usePatchFolders,
|
||||
@@ -133,37 +134,53 @@ const SideBarFoldersButtonsComponent = ({
|
||||
return;
|
||||
}
|
||||
|
||||
getObjectsFromFilelist<any>(files).then((objects) => {
|
||||
if (objects.every((flow) => flow.data?.nodes)) {
|
||||
uploadFlow({ files }).then(() => {
|
||||
setSuccessData({
|
||||
title: "Uploaded successfully",
|
||||
getObjectsFromFilelist<any>(files)
|
||||
.then((objects) => {
|
||||
if (objects.every((flow) => flow.data?.nodes)) {
|
||||
uploadFlow({ files })
|
||||
.then(() => {
|
||||
setSuccessData({
|
||||
title: "Uploaded successfully",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorData({
|
||||
title: UPLOAD_ERROR_ALERT,
|
||||
list: [
|
||||
error instanceof Error ? error.message : String(error),
|
||||
],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
files.forEach((folder) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", folder);
|
||||
mutate(
|
||||
{ formData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessData({
|
||||
title: "Project uploaded successfully.",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
setErrorData({
|
||||
title: `Error on uploading your project, try dragging it into an existing project.`,
|
||||
list: [err["response"]["data"]["message"]],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorData({
|
||||
title: UPLOAD_ERROR_ALERT,
|
||||
list: [error instanceof Error ? error.message : String(error)],
|
||||
});
|
||||
} else {
|
||||
files.forEach((folder) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", folder);
|
||||
mutate(
|
||||
{ formData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessData({
|
||||
title: "Project uploaded successfully.",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
setErrorData({
|
||||
title: `Error on uploading your project, try dragging it into an existing project.`,
|
||||
list: [err["response"]["data"]["message"]],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import ModelInputComponent, { ModelOption } from "../index";
|
||||
import ModelInputComponent from "../index";
|
||||
import type { ModelOption } from "../types";
|
||||
|
||||
// Mock scrollIntoView for cmdk library
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
@@ -14,6 +16,20 @@ jest.mock("@/stores/alertStore", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useRefreshModelInputs with controllable promise
|
||||
let mockRefreshResolve: () => void;
|
||||
const mockRefreshAllModelInputs = jest.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
mockRefreshResolve = resolve;
|
||||
}),
|
||||
);
|
||||
jest.mock("@/hooks/use-refresh-model-inputs", () => ({
|
||||
useRefreshModelInputs: () => ({
|
||||
refreshAllModelInputs: mockRefreshAllModelInputs,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/flowStore", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
@@ -141,6 +157,19 @@ const defaultProps: any = {
|
||||
editNode: false,
|
||||
};
|
||||
|
||||
// Helper to render with QueryClientProvider
|
||||
const renderWithQueryClient = (component: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ModelInputComponent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -148,21 +177,25 @@ describe("ModelInputComponent", () => {
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render loading state when no options are provided", () => {
|
||||
render(<ModelInputComponent {...defaultProps} options={[]} />);
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} options={[]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("loading-text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the model selector when options are available", () => {
|
||||
render(<ModelInputComponent {...defaultProps} />);
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
// Should show the dropdown trigger
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display placeholder text when no model is selected", () => {
|
||||
render(<ModelInputComponent {...defaultProps} value={[]} />);
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} value={[]} />,
|
||||
);
|
||||
|
||||
// Initially selects first model, but let's check the UI is present
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
@@ -179,7 +212,9 @@ describe("ModelInputComponent", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<ModelInputComponent {...defaultProps} value={selectedValue} />);
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} value={selectedValue} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
@@ -187,7 +222,9 @@ describe("ModelInputComponent", () => {
|
||||
});
|
||||
|
||||
it("should render disabled state correctly", () => {
|
||||
render(<ModelInputComponent {...defaultProps} disabled={true} />);
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} disabled={true} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("combobox");
|
||||
expect(button).toBeDisabled();
|
||||
@@ -197,7 +234,7 @@ describe("ModelInputComponent", () => {
|
||||
describe("Dropdown Interaction", () => {
|
||||
it("should open dropdown when trigger is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInputComponent {...defaultProps} />);
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
@@ -210,7 +247,7 @@ describe("ModelInputComponent", () => {
|
||||
|
||||
it("should show model options grouped by provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInputComponent {...defaultProps} />);
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
@@ -227,7 +264,7 @@ describe("ModelInputComponent", () => {
|
||||
const handleOnNewValue = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent
|
||||
{...defaultProps}
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
@@ -251,7 +288,7 @@ describe("ModelInputComponent", () => {
|
||||
describe("Model Provider Modal", () => {
|
||||
it("should open manage providers dialog when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInputComponent {...defaultProps} />);
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
// Open dropdown first
|
||||
const trigger = screen.getByRole("combobox");
|
||||
@@ -276,7 +313,7 @@ describe("ModelInputComponent", () => {
|
||||
describe("Footer Buttons", () => {
|
||||
it("should render Manage Model Providers button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInputComponent {...defaultProps} />);
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
@@ -287,6 +324,92 @@ describe("ModelInputComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh List", () => {
|
||||
it("should close popover before entering loading state when refresh is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-model-list");
|
||||
await user.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockRefreshResolve();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Popover must be closed after refresh to prevent width measurement glitch
|
||||
expect(screen.queryByTestId("gpt-4-option")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("OpenAI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not crash when component renders without popover open during refresh", () => {
|
||||
mockRefreshAllModelInputs.mockImplementationOnce(() => Promise.resolve());
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("gpt-4-option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call refresh with silent flag exactly once per click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-model-list");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(mockRefreshAllModelInputs).toHaveBeenCalledTimes(1);
|
||||
expect(mockRefreshAllModelInputs).toHaveBeenCalledWith({ silent: true });
|
||||
|
||||
mockRefreshResolve();
|
||||
});
|
||||
|
||||
it("should recover to normal state when refresh rejects", async () => {
|
||||
// handleRefreshButtonPress uses try/finally, so refreshOptions resets even on error
|
||||
mockRefreshAllModelInputs.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error("Network error")),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-model-list");
|
||||
await user.click(refreshButton);
|
||||
|
||||
// finally block sets refreshOptions=false, restoring the combobox
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Loading models")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should filter out disabled provider models from grouped options", () => {
|
||||
const optionsWithDisabled: ModelOption[] = [
|
||||
@@ -300,7 +423,7 @@ describe("ModelInputComponent", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} options={optionsWithDisabled} />,
|
||||
);
|
||||
|
||||
@@ -309,7 +432,9 @@ describe("ModelInputComponent", () => {
|
||||
});
|
||||
|
||||
it("should handle empty value array", () => {
|
||||
render(<ModelInputComponent {...defaultProps} value={[]} />);
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent {...defaultProps} value={[]} />,
|
||||
);
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
@@ -318,7 +443,7 @@ describe("ModelInputComponent", () => {
|
||||
it("should auto-select first model when value is empty and options exist", () => {
|
||||
const handleOnNewValue = jest.fn();
|
||||
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<ModelInputComponent
|
||||
{...defaultProps}
|
||||
value={[]}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RefObject } from "react";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { RECEIVING_INPUT_VALUE } from "@/constants/constants";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { RefObject } from "react";
|
||||
import { ModelOption, SelectedModel } from "../types";
|
||||
|
||||
interface ModelTriggerProps {
|
||||
@@ -16,6 +16,7 @@ interface ModelTriggerProps {
|
||||
onOpenManageProviders: () => void;
|
||||
id: string;
|
||||
refButton: RefObject<HTMLButtonElement | null>;
|
||||
showEmptyState?: boolean;
|
||||
}
|
||||
|
||||
const ModelTrigger = ({
|
||||
@@ -28,6 +29,7 @@ const ModelTrigger = ({
|
||||
onOpenManageProviders,
|
||||
id,
|
||||
refButton,
|
||||
showEmptyState = false,
|
||||
}: ModelTriggerProps) => {
|
||||
const renderSelectedIcon = () => {
|
||||
if (disabled || options.length === 0) {
|
||||
@@ -42,7 +44,10 @@ const ModelTrigger = ({
|
||||
) : null;
|
||||
};
|
||||
|
||||
if (!hasEnabledProviders) {
|
||||
// Check if we're in empty state mode (showEmptyState=true and no options)
|
||||
const isEmptyStateMode = showEmptyState && options.length === 0;
|
||||
|
||||
if (!hasEnabledProviders && !showEmptyState && options.length === 0) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -60,7 +65,7 @@ const ModelTrigger = ({
|
||||
<div className="flex w-full flex-col">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabled={disabled || options.length === 0}
|
||||
disabled={disabled || (options.length === 0 && !showEmptyState)}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
role="combobox"
|
||||
@@ -80,6 +85,10 @@ const ModelTrigger = ({
|
||||
<span className="truncate">
|
||||
{disabled ? (
|
||||
RECEIVING_INPUT_VALUE
|
||||
) : isEmptyStateMode ? (
|
||||
<div className="truncate text-muted-foreground">
|
||||
No models enabled
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -4,6 +4,7 @@ import LoadingTextComponent from "@/components/common/loadingTextComponent";
|
||||
import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
|
||||
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
|
||||
import { usePostTemplateValue } from "@/controllers/API/queries/nodes/use-post-template-value";
|
||||
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
|
||||
import ModelProviderModal from "@/modals/modelProviderModal";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import type { APIClassType } from "@/types/api";
|
||||
@@ -18,7 +19,11 @@ import {
|
||||
import type { BaseInputProps } from "../../types";
|
||||
import ModelList from "./components/ModelList";
|
||||
import ModelTrigger from "./components/ModelTrigger";
|
||||
import { ModelInputComponentType, ModelOption } from "./types";
|
||||
import type {
|
||||
ModelInputComponentType,
|
||||
ModelOption,
|
||||
SelectedModel,
|
||||
} from "./types";
|
||||
|
||||
export default function ModelInputComponent({
|
||||
id,
|
||||
@@ -41,6 +46,9 @@ export default function ModelInputComponent({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openManageProvidersDialog, setOpenManageProvidersDialog] =
|
||||
useState(false);
|
||||
const [isRefreshingAfterClose, setIsRefreshingAfterClose] = useState(false);
|
||||
const [refreshOptions, setRefreshOptions] = useState(false);
|
||||
const { refreshAllModelInputs } = useRefreshModelInputs();
|
||||
|
||||
// Ref to track if we've already processed the empty options state
|
||||
// prevents infinite loop when no models are available
|
||||
@@ -57,16 +65,20 @@ export default function ModelInputComponent({
|
||||
? "llm"
|
||||
: "embeddings";
|
||||
|
||||
const { data: providersData = [], isLoading: isLoadingProviders } =
|
||||
useGetModelProviders({});
|
||||
const {
|
||||
data: providersData = [],
|
||||
isLoading: isLoadingProviders,
|
||||
isFetching: isFetchingProviders,
|
||||
} = useGetModelProviders({});
|
||||
const { data: enabledModelsData, isLoading: isLoadingEnabledModels } =
|
||||
useGetEnabledModels();
|
||||
|
||||
const isLoading = isLoadingProviders || isLoadingEnabledModels;
|
||||
|
||||
// Determines if we should show the model selector or the "Setup Provider" button
|
||||
const hasEnabledProviders = useMemo(() => {
|
||||
return providersData?.some((provider) => provider.is_enabled);
|
||||
return providersData?.some(
|
||||
(provider) => provider.is_enabled || provider.is_configured,
|
||||
);
|
||||
}, [providersData]);
|
||||
|
||||
// Groups models by their provider name for sectioned display in dropdown.
|
||||
@@ -172,10 +184,48 @@ export default function ModelInputComponent({
|
||||
[flatOptions, handleOnNewValue],
|
||||
);
|
||||
|
||||
const handleRefreshButtonPress = useCallback(async () => {
|
||||
setOpen(false);
|
||||
setRefreshOptions(true);
|
||||
try {
|
||||
await refreshAllModelInputs({ silent: true });
|
||||
} catch {
|
||||
// refreshAllModelInputs handles its own error notifications via alertStore
|
||||
} finally {
|
||||
setRefreshOptions(false);
|
||||
}
|
||||
}, [refreshAllModelInputs]);
|
||||
|
||||
const handleManageProvidersDialogClose = useCallback(() => {
|
||||
setOpenManageProvidersDialog(false);
|
||||
setIsRefreshingAfterClose(true);
|
||||
}, []);
|
||||
|
||||
// Clear the refreshing indicator after the providers query completes a full
|
||||
// refetch cycle (isFetchingProviders: false → true → false). We track whether
|
||||
// we've seen the fetch start so we don't clear prematurely before the
|
||||
// invalidation has even been triggered by refreshAllModelInputs.
|
||||
const hasSeenFetchStartRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isRefreshingAfterClose) {
|
||||
hasSeenFetchStartRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isFetchingProviders) {
|
||||
hasSeenFetchStartRef.current = true;
|
||||
} else if (hasSeenFetchStartRef.current) {
|
||||
setIsRefreshingAfterClose(false);
|
||||
}
|
||||
}, [isRefreshingAfterClose, isFetchingProviders]);
|
||||
|
||||
// Safety timeout: clear loading even if no refetch cycle is detected
|
||||
// (e.g. no model nodes on canvas, or the refresh was a no-op)
|
||||
useEffect(() => {
|
||||
if (!isRefreshingAfterClose) return;
|
||||
const timeout = setTimeout(() => setIsRefreshingAfterClose(false), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isRefreshingAfterClose]);
|
||||
|
||||
const renderLoadingButton = () => (
|
||||
<Button
|
||||
className="dropdown-component-false-outline w-full justify-between py-2 font-normal"
|
||||
@@ -238,6 +288,12 @@ export default function ModelInputComponent({
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleModelSelect}
|
||||
/>
|
||||
{renderFooterButton(
|
||||
"Refresh List",
|
||||
"RotateCw",
|
||||
handleRefreshButtonPress,
|
||||
"refresh-model-list",
|
||||
)}
|
||||
{renderManageProvidersButton()}
|
||||
</Command>
|
||||
</PopoverContentInput>
|
||||
@@ -248,8 +304,12 @@ export default function ModelInputComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (!options || options.length === 0) {
|
||||
// Loading state (skip if showEmptyState is true - we want to show the empty dropdown instead)
|
||||
if (
|
||||
((!options || options.length === 0) && !showEmptyState) ||
|
||||
isRefreshingAfterClose ||
|
||||
refreshOptions
|
||||
) {
|
||||
return <div className="w-full">{renderLoadingButton()}</div>;
|
||||
}
|
||||
|
||||
@@ -268,6 +328,7 @@ export default function ModelInputComponent({
|
||||
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
|
||||
id={id}
|
||||
refButton={refButton}
|
||||
showEmptyState={showEmptyState}
|
||||
/>
|
||||
</div>
|
||||
{renderPopoverContent()}
|
||||
|
||||
@@ -12,4 +12,6 @@ export interface ModelInputComponentType {
|
||||
options?: ModelOption[];
|
||||
placeholder?: string;
|
||||
externalOptions?: any;
|
||||
/** When true and options are empty, shows "No models enabled" in a clickable dropdown instead of loading state */
|
||||
showEmptyState?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
type VisibilityToggleButtonProps = {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export default function VisibilityToggleButton({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: VisibilityToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
data-testid={id}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={checked ? "Hide field" : "Show field"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md p-1 transition-colors",
|
||||
"hover:bg-accent",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name={checked ? "Eye" : "EyeOff"}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import VisibilityToggleButton from "../VisibilityToggleButton";
|
||||
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
ForwardedIconComponent: ({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<span data-testid={`icon-${name}`} className={className}>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
id: "showtemplate",
|
||||
checked: true,
|
||||
disabled: false,
|
||||
onToggle: jest.fn(),
|
||||
};
|
||||
|
||||
describe("VisibilityToggleButton", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Happy path tests
|
||||
|
||||
it("should_render_eye_icon_when_checked_is_true", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={true} />);
|
||||
|
||||
expect(screen.getByTestId("icon-Eye")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("icon-EyeOff")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_render_eyeoff_icon_when_checked_is_false", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={false} />);
|
||||
|
||||
expect(screen.getByTestId("icon-EyeOff")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("icon-Eye")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_call_onToggle_when_clicked", () => {
|
||||
const onToggle = jest.fn();
|
||||
render(<VisibilityToggleButton {...defaultProps} onToggle={onToggle} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("showtemplate"));
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should_have_correct_data_testid", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} id="showpath" />);
|
||||
|
||||
expect(screen.getByTestId("showpath")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_have_correct_id_attribute", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} id="showpath" />);
|
||||
|
||||
const button = screen.getByTestId("showpath");
|
||||
expect(button.id).toBe("showpath");
|
||||
});
|
||||
|
||||
it("should_have_role_switch", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_have_aria_checked_true_when_checked", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={true} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("should_have_aria_checked_false_when_unchecked", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={false} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
// Adversarial tests
|
||||
|
||||
it("should_be_disabled_when_disabled_prop_is_true", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} disabled={true} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should_not_call_onToggle_when_disabled_and_clicked", () => {
|
||||
const onToggle = jest.fn();
|
||||
render(
|
||||
<VisibilityToggleButton
|
||||
{...defaultProps}
|
||||
disabled={true}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("showtemplate"));
|
||||
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should_stop_event_propagation_on_click", () => {
|
||||
const parentOnClick = jest.fn();
|
||||
render(
|
||||
<div onClick={parentOnClick}>
|
||||
<VisibilityToggleButton {...defaultProps} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("showtemplate"));
|
||||
|
||||
expect(parentOnClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should_have_hide_aria_label_when_checked", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={true} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Hide field",
|
||||
);
|
||||
});
|
||||
|
||||
it("should_have_show_aria_label_when_unchecked", () => {
|
||||
render(<VisibilityToggleButton {...defaultProps} checked={false} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Show field",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import useFlowStore from "@/stores/flowStore";
|
||||
import { useTweaksStore } from "@/stores/tweaksStore";
|
||||
import type { APIClassType } from "@/types/api";
|
||||
import { isTargetHandleConnected } from "@/utils/reactflowUtils";
|
||||
import ToggleShadComponent from "../../../toggleShadComponent";
|
||||
import VisibilityToggleButton from "./VisibilityToggleButton";
|
||||
|
||||
export default function TableAdvancedToggleCellRender({
|
||||
value: { nodeId, parameterId, isTweaks },
|
||||
@@ -47,13 +47,11 @@ export default function TableAdvancedToggleCellRender({
|
||||
styleClasses="z-50"
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ToggleShadComponent
|
||||
disabled={disabled}
|
||||
value={!parameter.advanced}
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
editNode={true}
|
||||
showToogle
|
||||
<VisibilityToggleButton
|
||||
id={"show" + parameterId}
|
||||
checked={!parameter.advanced}
|
||||
disabled={disabled}
|
||||
onToggle={() => handleOnNewValue({ advanced: !parameter.advanced })}
|
||||
/>
|
||||
</div>
|
||||
</ShadTooltip>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ChatHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center border-b border-transparent bg-background relative overflow-visible",
|
||||
"flex items-center border-b border-transparent relative overflow-visible",
|
||||
"justify-between px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -83,7 +83,7 @@ export function ChatSidebar({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sessionIds.map((session, index) => (
|
||||
{sessionIds.map((session) => (
|
||||
<SessionSelector
|
||||
key={session}
|
||||
session={session}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function SessionSelector({
|
||||
}
|
||||
};
|
||||
|
||||
// Default session (flowId) cannot be renamed or deleted
|
||||
// Default session (flowId) cannot be renamed, but can be deleted if it has messages
|
||||
const isDefaultSession = session === currentFlowId;
|
||||
|
||||
const hasMessages = useSessionHasMessages({
|
||||
@@ -96,6 +96,7 @@ export function SessionSelector({
|
||||
});
|
||||
|
||||
const canModifySession = !isDefaultSession;
|
||||
const canDeleteSession = hasMessages;
|
||||
const canRenameSession = canModifySession && hasMessages;
|
||||
|
||||
return (
|
||||
@@ -144,7 +145,7 @@ export function SessionSelector({
|
||||
onMessageLogs={() => inspectSession?.(session)}
|
||||
onDelete={() => deleteSession(session)}
|
||||
showRename={canRenameSession}
|
||||
showDelete={canModifySession}
|
||||
showDelete={canDeleteSession}
|
||||
side="bottom"
|
||||
align="end"
|
||||
dataTestid={`session-${session}-more-menu`}
|
||||
|
||||
@@ -37,8 +37,8 @@ export const useEditSessionInfo = ({
|
||||
const { mutate: deleteSession } = useDeleteSession();
|
||||
|
||||
const handleDelete = (sessionId: string) => {
|
||||
if (sessionId && dbSessions.includes(sessionId)) {
|
||||
deleteSession({ sessionId: sessionId });
|
||||
if (sessionId && flowId) {
|
||||
deleteSession({ sessionId: sessionId, flowId: flowId });
|
||||
}
|
||||
if (flowId && sessionId === selectedSession) {
|
||||
setSelectedSession(flowId);
|
||||
|
||||
@@ -138,6 +138,7 @@ export const useGetAddSessions: UseGetAddSessionsReturnType = ({
|
||||
};
|
||||
|
||||
const removeLocalSession = (sessionId: string) => {
|
||||
// Update state - the useEffect on line 67-77 will sync to sessionStorage
|
||||
setLocalSessions((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(sessionId);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Square } from "lucide-react";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import type { FilePreviewType } from "@/types/components";
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
@@ -14,14 +16,16 @@ type ButtonSendWrapperProps = {
|
||||
noInput: boolean;
|
||||
chatValue: string;
|
||||
files: FilePreviewType[];
|
||||
isBuilding?: boolean;
|
||||
};
|
||||
|
||||
const ButtonSendWrapper = ({
|
||||
send,
|
||||
noInput,
|
||||
chatValue,
|
||||
files,
|
||||
isBuilding,
|
||||
}: ButtonSendWrapperProps) => {
|
||||
const stopBuilding = useFlowStore((state) => state.stopBuilding);
|
||||
const isLoading = files.some((file) => file.loading);
|
||||
|
||||
const getButtonState = () => {
|
||||
@@ -33,7 +37,10 @@ const ButtonSendWrapper = ({
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!isLoading) {
|
||||
|
||||
if (isBuilding) {
|
||||
stopBuilding();
|
||||
} else if (!isLoading) {
|
||||
send();
|
||||
}
|
||||
};
|
||||
@@ -48,9 +55,14 @@ const ButtonSendWrapper = ({
|
||||
disabled={isLoading}
|
||||
unstyled
|
||||
data-testid="button-send"
|
||||
title={isBuilding ? "Cancel" : "Send"}
|
||||
>
|
||||
<div className="flex h-fit w-fit items-center gap-2 text-sm font-medium">
|
||||
<ForwardedIconComponent name="ArrowUp" className="h-4 w-4" />
|
||||
{isBuilding ? (
|
||||
<Square className="h-3.5 w-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ForwardedIconComponent name="ArrowUp" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ const InputWrapper = ({
|
||||
{/* Input container */}
|
||||
<div
|
||||
data-testid="input-wrapper"
|
||||
className="flex w-full flex-col rounded-md border border-input bg-background p-3 cursor-text hover:border-muted-foreground focus-within:border-primary"
|
||||
className="flex w-full flex-col rounded-md border border-input bg-muted p-3 cursor-text hover:border-muted-foreground focus-within:border-primary"
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
@@ -129,6 +129,7 @@ const InputWrapper = ({
|
||||
isSupported={isAudioSupported}
|
||||
/>
|
||||
<ButtonSendWrapper
|
||||
isBuilding={isBuilding}
|
||||
send={send}
|
||||
noInput={noInput}
|
||||
chatValue={chatValue}
|
||||
|
||||
@@ -166,12 +166,12 @@ export const UserMessage = memo(
|
||||
</>
|
||||
)}
|
||||
{chat.files && chat.files.length > 0 && (
|
||||
<div className="mt-2 flex w-full items-center gap-4 overflow-auto">
|
||||
<div className="mt-2 flex w-full items-start gap-4 overflow-auto">
|
||||
{chat.files.map((file, index) => (
|
||||
<FilePreviewDisplay
|
||||
key={index}
|
||||
file={file}
|
||||
variant="compact"
|
||||
variant="expanded"
|
||||
showDelete={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGetFlowId } from "@/components/core/playgroundComponent/hooks/use-ge
|
||||
import { useGetMessagesQuery } from "@/controllers/API/queries/messages";
|
||||
import type { ChatMessageType } from "@/types/chat";
|
||||
import type { Message } from "@/types/messages";
|
||||
import { isMessageForSession } from "../../utils/session-filter";
|
||||
import sortSenderMessages from "../utils/sort-sender-messages";
|
||||
|
||||
export const useChatHistory = (visibleSession: string | null) => {
|
||||
@@ -45,17 +46,20 @@ export const useChatHistory = (visibleSession: string | null) => {
|
||||
if (queryData && typeof queryData === "object" && "rows" in queryData) {
|
||||
const rowsData = queryData.rows as { data?: Message[] } | undefined;
|
||||
if (rowsData && typeof rowsData === "object" && "data" in rowsData) {
|
||||
const backendMessages = rowsData.data || [];
|
||||
const backendMessages = (rowsData.data || []).filter((msg: Message) =>
|
||||
isMessageForSession(msg, currentFlowId, visibleSession),
|
||||
);
|
||||
|
||||
const existingCache =
|
||||
queryClient.getQueryData<Message[]>(sessionCacheKey) || [];
|
||||
|
||||
// Only initialize if cache is empty and we have backend messages
|
||||
// Only initialize if cache is empty and we have backend messages for this session
|
||||
if (existingCache.length === 0 && backendMessages.length > 0) {
|
||||
queryClient.setQueryData(sessionCacheKey, backendMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [queryData, queryClient, sessionCacheKey]);
|
||||
}, [queryData, queryClient, sessionCacheKey, currentFlowId, visibleSession]);
|
||||
|
||||
// Use session cache as the single source of truth
|
||||
// updateMessage and addUserMessage handle all updates (placeholders, streaming, etc.)
|
||||
@@ -65,21 +69,10 @@ export const useChatHistory = (visibleSession: string | null) => {
|
||||
const chatHistory = useMemo(() => {
|
||||
// Filter messages for current session
|
||||
const filteredMessages: ChatMessageType[] = messages
|
||||
.filter((message: Message) => {
|
||||
const isCurrentFlow = message.flow_id === currentFlowId;
|
||||
// If visibleSession is the flow_id, it means we are in the default session
|
||||
// In the default session, we show messages that have the same session_id as the flow_id
|
||||
// OR messages that have NO session_id (legacy behavior)
|
||||
if (visibleSession === currentFlowId) {
|
||||
const matches =
|
||||
isCurrentFlow &&
|
||||
(message.session_id === visibleSession || !message.session_id);
|
||||
return matches;
|
||||
}
|
||||
const matches = isCurrentFlow && message.session_id === visibleSession;
|
||||
return matches;
|
||||
})
|
||||
.map((message: Message) => {
|
||||
.filter((message: Message) =>
|
||||
isMessageForSession(message, currentFlowId, visibleSession),
|
||||
)
|
||||
.map((message: Message): ChatMessageType => {
|
||||
let files = message.files;
|
||||
// Handle the "[]" case, empty string, or already parsed array
|
||||
if (Array.isArray(files)) {
|
||||
@@ -96,6 +89,28 @@ export const useChatHistory = (visibleSession: string | null) => {
|
||||
}
|
||||
const messageText = message.text || "";
|
||||
|
||||
// Convert Message.properties to ChatMessageType.properties (PropertiesType)
|
||||
// Properties are now properly typed in Message, no cast needed
|
||||
let properties: ChatMessageType["properties"] = undefined;
|
||||
if (message.properties?.source?.id) {
|
||||
properties = {
|
||||
source: {
|
||||
id: message.properties.source.id,
|
||||
display_name: message.properties.source.display_name || "",
|
||||
source: message.properties.source.source || "",
|
||||
},
|
||||
state: message.properties.state,
|
||||
icon: message.properties.icon,
|
||||
background_color: message.properties.background_color,
|
||||
text_color: message.properties.text_color,
|
||||
targets: message.properties.targets,
|
||||
edited: message.properties.edited,
|
||||
allow_markdown: message.properties.allow_markdown,
|
||||
positive_feedback: message.properties.positive_feedback,
|
||||
build_duration: message.properties.build_duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isSend: message.sender === "User",
|
||||
message: messageText,
|
||||
@@ -110,7 +125,7 @@ export const useChatHistory = (visibleSession: string | null) => {
|
||||
text_color: message.text_color,
|
||||
content_blocks: message.content_blocks,
|
||||
category: message.category,
|
||||
properties: message.properties,
|
||||
properties: properties,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { StickToBottom } from "use-stick-to-bottom";
|
||||
import { SafariScrollFix } from "@/components/common/safari-scroll-fix";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { usePlaygroundStore } from "@/stores/playgroundStore";
|
||||
import { ChatMessageType } from "@/types/chat";
|
||||
import type { ChatMessageType } from "@/types/chat";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { BotMessage } from "./components/bot-message";
|
||||
import ChatMessage from "./components/chat-message";
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function FilePreviewDisplay({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-16 w-16 items-center justify-center rounded-md border bg-muted",
|
||||
"relative flex w-full lg:w-1/2 items-center justify-center rounded-md border bg-muted",
|
||||
error && "border-error",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Message } from "@/types/messages";
|
||||
|
||||
/**
|
||||
* Determines if a message belongs to a specific session.
|
||||
*
|
||||
* Why this logic exists:
|
||||
* - Default session (sessionId === flowId): Shows messages with matching session_id OR no session_id (legacy)
|
||||
* - Named sessions: Only shows messages with exact session_id match
|
||||
* - This prevents cross-session data leakage after deletions
|
||||
*
|
||||
* @param msg - The message to check
|
||||
* @param flowId - The current flow ID
|
||||
* @param sessionId - The session ID to filter for (null means no filtering)
|
||||
* @returns true if the message belongs to the session
|
||||
*/
|
||||
export function isMessageForSession(
|
||||
msg: Message,
|
||||
flowId: string,
|
||||
sessionId: string | null,
|
||||
): boolean {
|
||||
if (!sessionId) return false;
|
||||
|
||||
const isCurrentFlow = msg.flow_id === flowId;
|
||||
|
||||
if (sessionId === flowId) {
|
||||
// Default session: include messages with matching session_id or no session_id (legacy behavior)
|
||||
return isCurrentFlow && (msg.session_id === sessionId || !msg.session_id);
|
||||
}
|
||||
|
||||
return isCurrentFlow && msg.session_id === sessionId;
|
||||
}
|
||||
@@ -125,6 +125,17 @@ export function FlowPageSlidingContainerContent({
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(isFullscreen);
|
||||
}, [isFullscreen]);
|
||||
@@ -182,7 +193,7 @@ export function FlowPageSlidingContainerContent({
|
||||
>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<AnimatedConditional isOpen={sidebarOpen} width="236px">
|
||||
<div className="h-full overflow-y-auto border-r border-border w-218">
|
||||
<div className="h-full overflow-y-auto border-r border-border w-218 bg-primary-foreground">
|
||||
<div className="p-4">
|
||||
<ChatSidebar
|
||||
sessions={orderedSessions}
|
||||
|
||||
66
src/frontend/src/components/ui/__tests__/dialog.test.tsx
Normal file
66
src/frontend/src/components/ui/__tests__/dialog.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "../dialog";
|
||||
|
||||
// Mock genericIconComponent (already globally mocked, but be explicit)
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const renderWithProviders = (ui: ReactElement) => {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
};
|
||||
|
||||
describe("DialogContent", () => {
|
||||
it("should_not_auto_focus_close_button_when_dialog_opens", () => {
|
||||
// Arrange — open dialog with default behavior (no custom onOpenAutoFocus)
|
||||
renderWithProviders(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Test Dialog</DialogTitle>
|
||||
<DialogDescription>Test description</DialogDescription>
|
||||
<p>Content</p>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
// Act — dialog is already open, focus should have been handled
|
||||
|
||||
// Assert — close button must NOT have focus
|
||||
const closeButton = screen.getByRole("button", { name: /close/i });
|
||||
expect(closeButton).not.toHaveFocus();
|
||||
|
||||
// Assert — "Close" tooltip must NOT be visible on open
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_call_custom_onOpenAutoFocus_when_provided", () => {
|
||||
// Arrange — provide a custom onOpenAutoFocus handler
|
||||
const customHandler = jest.fn((e: Event) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Dialog open>
|
||||
<DialogContent onOpenAutoFocus={customHandler}>
|
||||
<DialogTitle>Test Dialog</DialogTitle>
|
||||
<DialogDescription>Test description</DialogDescription>
|
||||
<p>Content</p>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
// Assert — custom handler was called
|
||||
expect(customHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type * as React from "react";
|
||||
import { cn } from "../../utils/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center border rounded-full px-2.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center border rounded-full px-2.5 font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -58,7 +58,14 @@ const DialogContent = React.forwardRef<
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, children, hideTitle = false, closeButtonClassName, ...props },
|
||||
{
|
||||
className,
|
||||
children,
|
||||
hideTitle = false,
|
||||
closeButtonClassName,
|
||||
onOpenAutoFocus,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Check if DialogTitle is included in children
|
||||
@@ -79,6 +86,13 @@ const DialogContent = React.forwardRef<
|
||||
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
|
||||
className,
|
||||
)}
|
||||
onOpenAutoFocus={(e) => {
|
||||
if (onOpenAutoFocus) {
|
||||
onOpenAutoFocus(e);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!hasDialogTitle && (
|
||||
@@ -100,7 +114,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
"absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
closeButtonClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-between gap-2 rounded-md border border-input px-4 py-2 text-sm text-primary ring-offset-background placeholder:text-muted-foreground hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-8 items-center justify-between gap-2 rounded-md border border-input px-4 py-2 text-sm text-primary ring-offset-background placeholder:text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import type { useMutationFunctionType } from "@/types/api";
|
||||
import type {
|
||||
DeleteSessionError,
|
||||
DeleteSessionParams,
|
||||
DeleteSessionResponse,
|
||||
} from "@/types/messages/session";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
|
||||
interface DeleteSessionParams {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const useDeleteSession: useMutationFunctionType<
|
||||
undefined,
|
||||
DeleteSessionParams
|
||||
> = (options?) => {
|
||||
export const useDeleteSession = (options?: {
|
||||
onSuccess?: (
|
||||
data: DeleteSessionResponse,
|
||||
variables: DeleteSessionParams,
|
||||
context: unknown,
|
||||
) => void;
|
||||
onSettled?: (
|
||||
data: DeleteSessionResponse | undefined,
|
||||
error: DeleteSessionError | null,
|
||||
variables: DeleteSessionParams,
|
||||
context: unknown,
|
||||
) => void;
|
||||
onError?: (error: DeleteSessionError) => void;
|
||||
}) => {
|
||||
const { mutate, queryClient } = UseRequestProcessor();
|
||||
|
||||
const deleteSession = async ({
|
||||
sessionId,
|
||||
}: DeleteSessionParams): Promise<any> => {
|
||||
}: DeleteSessionParams): Promise<DeleteSessionResponse> => {
|
||||
const response = await api.delete(
|
||||
`${getURL("MESSAGES")}/session/${sessionId}`,
|
||||
);
|
||||
@@ -24,16 +34,65 @@ export const useDeleteSession: useMutationFunctionType<
|
||||
};
|
||||
|
||||
const mutation: UseMutationResult<
|
||||
DeleteSessionParams,
|
||||
any,
|
||||
DeleteSessionResponse,
|
||||
DeleteSessionError,
|
||||
DeleteSessionParams
|
||||
> = mutate(["useDeleteSession"], deleteSession, {
|
||||
...options,
|
||||
onSettled: (data, error, variables, context) => {
|
||||
onSuccess: (data, variables, context, ...rest) => {
|
||||
// Cast needed because UseRequestProcessor's mutate doesn't properly infer callback types
|
||||
const vars = variables as unknown as DeleteSessionParams;
|
||||
|
||||
// Remove all message queries for this session immediately to prevent stale data
|
||||
if (vars.flowId) {
|
||||
// Remove session-specific queries
|
||||
queryClient.removeQueries({
|
||||
queryKey: [
|
||||
"useGetMessagesQuery",
|
||||
{ id: vars.flowId, session_id: vars.sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
// Also remove any queries that might have the session_id in params (e.g., Message Logs)
|
||||
queryClient.removeQueries({
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey;
|
||||
if (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "useGetMessagesQuery"
|
||||
) {
|
||||
const params = queryKey[1] as Record<string, unknown>;
|
||||
if (params?.params && typeof params.params === "object") {
|
||||
const nestedParams = params.params as Record<string, unknown>;
|
||||
if (nestedParams.session_id === vars.sessionId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
options?.onSuccess?.(data, vars, context);
|
||||
},
|
||||
onSettled: (data, error, variables, context, ...rest) => {
|
||||
// Cast needed because UseRequestProcessor's mutate doesn't properly infer callback types
|
||||
const vars = variables as unknown as DeleteSessionParams;
|
||||
|
||||
// Invalidate sessions list to refresh the sidebar
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetSessionsFromFlowQuery"],
|
||||
});
|
||||
options?.onSettled?.(data, error, variables, context);
|
||||
|
||||
// Invalidate all message queries to ensure fresh data everywhere
|
||||
if (vars.flowId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetMessagesQuery"],
|
||||
refetchType: "none", // Prevent automatic refetching to avoid race conditions
|
||||
});
|
||||
}
|
||||
|
||||
options?.onSettled?.(data, error, vars, context);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// Mock API before imports
|
||||
const mockApiGet = jest.fn();
|
||||
|
||||
@@ -11,23 +15,23 @@ jest.mock("@/controllers/API/helpers/constants", () => ({
|
||||
getURL: jest.fn((key) => `/api/v1/${key.toLowerCase()}`),
|
||||
}));
|
||||
|
||||
jest.mock("@/controllers/API/services/request-processor", () => ({
|
||||
UseRequestProcessor: jest.fn(() => ({
|
||||
query: jest.fn((_key, fn, _options) => {
|
||||
const result = { data: null, isLoading: false, error: null };
|
||||
fn().then((data: any) => {
|
||||
result.data = data;
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
ModelProviderInfo,
|
||||
useGetModelProviders,
|
||||
} from "../use-get-model-providers";
|
||||
|
||||
// Helper to render hooks with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useGetModelProviders", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -44,42 +48,58 @@ describe("useGetModelProviders", () => {
|
||||
];
|
||||
mockApiGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
useGetModelProviders({});
|
||||
renderHook(() => useGetModelProviders({}), { wrapper: createWrapper() });
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/models");
|
||||
await waitFor(() => {
|
||||
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/models");
|
||||
});
|
||||
});
|
||||
|
||||
it("should include deprecated param when includeDeprecated is true", async () => {
|
||||
mockApiGet.mockResolvedValue({ data: [] });
|
||||
|
||||
useGetModelProviders({ includeDeprecated: true });
|
||||
renderHook(() => useGetModelProviders({ includeDeprecated: true }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_deprecated=true",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_deprecated=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should include unsupported param when includeUnsupported is true", async () => {
|
||||
mockApiGet.mockResolvedValue({ data: [] });
|
||||
|
||||
useGetModelProviders({ includeUnsupported: true });
|
||||
renderHook(() => useGetModelProviders({ includeUnsupported: true }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_unsupported=true",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_unsupported=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should include both params when both are true", async () => {
|
||||
mockApiGet.mockResolvedValue({ data: [] });
|
||||
|
||||
useGetModelProviders({
|
||||
includeDeprecated: true,
|
||||
includeUnsupported: true,
|
||||
});
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_deprecated=true&include_unsupported=true",
|
||||
renderHook(
|
||||
() =>
|
||||
useGetModelProviders({
|
||||
includeDeprecated: true,
|
||||
includeUnsupported: true,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/models?include_deprecated=true&include_unsupported=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,8 +119,12 @@ describe("useGetModelProviders", () => {
|
||||
];
|
||||
mockApiGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = useGetModelProviders({});
|
||||
expect(result).toBeDefined();
|
||||
const { result } = renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should use Bot as default icon for unknown providers", async () => {
|
||||
@@ -113,8 +137,12 @@ describe("useGetModelProviders", () => {
|
||||
];
|
||||
mockApiGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = useGetModelProviders({});
|
||||
expect(result).toBeDefined();
|
||||
const { result } = renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,8 +167,12 @@ describe("useGetModelProviders", () => {
|
||||
];
|
||||
mockApiGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = useGetModelProviders({});
|
||||
expect(result).toBeDefined();
|
||||
const { result } = renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -150,7 +182,11 @@ describe("useGetModelProviders", () => {
|
||||
mockApiGet.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
// Should not throw, returns empty array
|
||||
expect(() => useGetModelProviders({})).not.toThrow();
|
||||
expect(() =>
|
||||
renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,15 +208,19 @@ describe("useGetModelProviders", () => {
|
||||
];
|
||||
mockApiGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = useGetModelProviders({});
|
||||
expect(result).toBeDefined();
|
||||
const { result } = renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle empty providers list", async () => {
|
||||
mockApiGet.mockResolvedValue({ data: [] });
|
||||
|
||||
const result = useGetModelProviders({});
|
||||
expect(result).toBeDefined();
|
||||
const { result } = renderHook(() => useGetModelProviders({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,32 +30,28 @@ export const useGetModelProviders: useQueryFunctionType<
|
||||
const { query } = UseRequestProcessor();
|
||||
|
||||
const getModelProvidersFn = async (): Promise<ModelProviderWithStatus[]> => {
|
||||
try {
|
||||
// Build query params
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.includeDeprecated) {
|
||||
queryParams.append("include_deprecated", "true");
|
||||
}
|
||||
if (params?.includeUnsupported) {
|
||||
queryParams.append("include_unsupported", "true");
|
||||
}
|
||||
|
||||
const url = `${getURL("MODELS")}${
|
||||
queryParams.toString() ? `?${queryParams.toString()}` : ""
|
||||
}`;
|
||||
|
||||
// Fetch the models with provider information including is_enabled status from server
|
||||
const response = await api.get<ModelProviderInfo[]>(url);
|
||||
const providersData = response.data;
|
||||
|
||||
return providersData.map((providerInfo) => ({
|
||||
...providerInfo,
|
||||
icon: getProviderIcon(providerInfo.provider),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching model providers:", error);
|
||||
return [];
|
||||
// Build query params
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.includeDeprecated) {
|
||||
queryParams.append("include_deprecated", "true");
|
||||
}
|
||||
if (params?.includeUnsupported) {
|
||||
queryParams.append("include_unsupported", "true");
|
||||
}
|
||||
|
||||
const url = `${getURL("MODELS")}${
|
||||
queryParams.toString() ? `?${queryParams.toString()}` : ""
|
||||
}`;
|
||||
|
||||
// Fetch the models with provider information including is_enabled status from server
|
||||
// Let errors propagate so React Query can retry and preserve stale data
|
||||
const response = await api.get<ModelProviderInfo[]>(url);
|
||||
const providersData = response.data;
|
||||
|
||||
return providersData.map((providerInfo) => ({
|
||||
...providerInfo,
|
||||
icon: getProviderIcon(providerInfo.provider),
|
||||
}));
|
||||
};
|
||||
|
||||
const queryResult = query(
|
||||
|
||||
@@ -421,7 +421,7 @@ describe("refreshAllModelInputs", () => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should prevent concurrent refresh operations", async () => {
|
||||
it("should queue concurrent refresh operations", async () => {
|
||||
mockNodes = [createMockModelNode("node-1")];
|
||||
|
||||
let resolveFirst: () => void;
|
||||
@@ -462,8 +462,8 @@ describe("refreshAllModelInputs", () => {
|
||||
await firstRefresh;
|
||||
await secondRefresh;
|
||||
|
||||
// API should only have been called once (second call was blocked)
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
// Second call should be queued and run after first completes (2 total calls)
|
||||
expect(api.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,12 @@ export interface RefreshOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
// Prevents concurrent refresh operations
|
||||
// Prevents concurrent refresh operations; queues the latest request if busy
|
||||
let isRefreshInProgress = false;
|
||||
let pendingRefresh: {
|
||||
queryClient?: QueryClient;
|
||||
options?: RefreshOptions;
|
||||
} | null = null;
|
||||
|
||||
/** Checks if a node has a model-type input field */
|
||||
export function isModelNode(node: AllNodeType): boolean {
|
||||
@@ -73,7 +77,11 @@ export async function refreshAllModelInputs(
|
||||
queryClient?: QueryClient,
|
||||
options?: RefreshOptions,
|
||||
): Promise<void> {
|
||||
if (isRefreshInProgress) return;
|
||||
if (isRefreshInProgress) {
|
||||
// Queue the latest request so it runs after the current one finishes
|
||||
pendingRefresh = { queryClient, options };
|
||||
return;
|
||||
}
|
||||
isRefreshInProgress = true;
|
||||
|
||||
const { setSuccessData, setErrorData } = useAlertStore.getState();
|
||||
@@ -86,10 +94,12 @@ export async function refreshAllModelInputs(
|
||||
const folderId = useFlowsManagerStore.getState().currentFlow?.folder_id;
|
||||
|
||||
if (queryClient) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetModelProviders"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] }),
|
||||
]);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["useGetModelProviders"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["useGetEnabledModels"],
|
||||
});
|
||||
}
|
||||
|
||||
const nodesWithModelFields = allNodes.filter(isModelNode);
|
||||
@@ -121,6 +131,12 @@ export async function refreshAllModelInputs(
|
||||
}
|
||||
} finally {
|
||||
isRefreshInProgress = false;
|
||||
// If another refresh was requested while this one was running, run it now
|
||||
if (pendingRefresh) {
|
||||
const { queryClient: qc, options: opts } = pendingRefresh;
|
||||
pendingRefresh = null;
|
||||
await refreshAllModelInputs(qc, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export const ChatViewWrapper = ({
|
||||
<div
|
||||
className={cn(
|
||||
sidebarOpen ? "pointer-events-none opacity-0" : "",
|
||||
"flex items-center justify-center rounded-sm ring-offset-background transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"flex items-center justify-center rounded-sm ring-offset-background transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
playgroundPage ? "right-2 top-4" : "absolute right-12 top-2 h-8",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -56,9 +56,7 @@ export default function SessionView({
|
||||
const rowsData = queryData.rows as { data?: any[] } | undefined;
|
||||
if (rowsData && typeof rowsData === "object" && "data" in rowsData) {
|
||||
const fetchedMessages = rowsData.data || [];
|
||||
if (fetchedMessages.length > 0) {
|
||||
setMessages(fetchedMessages);
|
||||
}
|
||||
setMessages(fetchedMessages);
|
||||
}
|
||||
}
|
||||
}, [queryData, setMessages]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
@@ -91,23 +91,35 @@ jest.mock("@/stores/alertStore", () => {
|
||||
return { __esModule: true, default: store };
|
||||
});
|
||||
|
||||
interface MockModelInputProps {
|
||||
value: { id: string; name: string }[];
|
||||
handleOnNewValue: (val: { value: { id: string; name: string }[] }) => void;
|
||||
options: { id: string; name: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Renders as a plain <select> so tests can inspect options and fire selections
|
||||
jest.mock(
|
||||
"@/components/core/parameterRenderComponent/components/modelInputComponent",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ value, handleOnNewValue, options, placeholder }: any) => (
|
||||
default: ({
|
||||
value,
|
||||
handleOnNewValue,
|
||||
options,
|
||||
placeholder,
|
||||
}: MockModelInputProps) => (
|
||||
<div data-testid="mock-model-input">
|
||||
<select
|
||||
data-testid="embedding-model-select"
|
||||
value={value?.[0]?.id || ""}
|
||||
onChange={(e) => {
|
||||
const selected = options?.find((o: any) => o.id === e.target.value);
|
||||
const selected = options?.find((o) => o.id === e.target.value);
|
||||
if (selected) handleOnNewValue({ value: [selected] });
|
||||
}}
|
||||
>
|
||||
<option value="">{placeholder || "Select..."}</option>
|
||||
{options?.map((opt: any) => (
|
||||
{options?.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
@@ -498,6 +510,109 @@ describe("KnowledgeBaseUploadModal", () => {
|
||||
expect(screen.getByText("file-a.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("file-b.txt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out unsupported file types and shows an error message with excluded files", async () => {
|
||||
render(<KnowledgeBaseUploadModal open={true} setOpen={jest.fn()} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const fileInput = document.getElementById(
|
||||
"file-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const validFile = new File(["content"], "valid.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const invalidFile = new File(["content"], "invalid.exe", {
|
||||
type: "application/x-msdownload",
|
||||
});
|
||||
|
||||
// Manually trigger the change event to bypass userEvent.upload's attribute-based filtering
|
||||
const event = {
|
||||
target: {
|
||||
files: [validFile, invalidFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
fireEvent.change(fileInput, event);
|
||||
|
||||
// Only the valid file should be rendered in the FilesPanel
|
||||
expect(screen.getByText("valid.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("invalid.exe")).not.toBeInTheDocument();
|
||||
|
||||
// Verify that the alert store was called with the correct error information
|
||||
expect(mockSetErrorData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining("Some files were skipped"),
|
||||
list: expect.arrayContaining(["invalid.exe"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out unsupported file types during folder upload", async () => {
|
||||
render(<KnowledgeBaseUploadModal open={true} setOpen={jest.fn()} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const folderInput = document.getElementById(
|
||||
"folder-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const validFile = new File(["content"], "valid.md", {
|
||||
type: "text/markdown",
|
||||
});
|
||||
const invalidFile = new File(["content"], "invalid.exe", {
|
||||
type: "application/x-msdownload",
|
||||
});
|
||||
|
||||
// Manually trigger the change event
|
||||
const event = {
|
||||
target: {
|
||||
files: [validFile, invalidFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
fireEvent.change(folderInput, event);
|
||||
|
||||
expect(screen.getByText("valid.md")).toBeInTheDocument();
|
||||
expect(screen.queryByText("invalid.exe")).not.toBeInTheDocument();
|
||||
|
||||
expect(mockSetErrorData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
list: expect.arrayContaining(["invalid.exe"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies file panel doesn't open and error is shown when ALL files are unsupported", async () => {
|
||||
render(<KnowledgeBaseUploadModal open={true} setOpen={jest.fn()} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const fileInput = document.getElementById(
|
||||
"file-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const invalidFile = new File(["content"], "invalid.exe", {
|
||||
type: "application/x-msdownload",
|
||||
});
|
||||
|
||||
const event = {
|
||||
target: {
|
||||
files: [invalidFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
fireEvent.change(fileInput, event);
|
||||
|
||||
// The FilesPanel (implied by file names being visible) should not be open
|
||||
expect(screen.queryByText("invalid.exe")).not.toBeInTheDocument();
|
||||
|
||||
// Verify that the error notification was shown
|
||||
expect(mockSetErrorData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining("Some files were skipped"),
|
||||
list: expect.arrayContaining(["invalid.exe"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Step 2 Review ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -156,8 +156,10 @@ export function StepConfiguration({
|
||||
<input
|
||||
id="folder-input"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onFolderSelect}
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
{...({
|
||||
webkitdirectory: "",
|
||||
directory: "",
|
||||
|
||||
@@ -26,6 +26,8 @@ export const KB_INGEST_FORMATS: Record<string, string[]> = {
|
||||
"adoc",
|
||||
"asciidoc",
|
||||
"asc",
|
||||
"pdf",
|
||||
"docx",
|
||||
],
|
||||
spreadsheets: ["csv"],
|
||||
code: ["py", "js", "ts", "tsx", "sh", "sql"],
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_SEPARATOR,
|
||||
KB_INGEST_EXTENSIONS,
|
||||
KB_NAME_REGEX,
|
||||
MAX_TOTAL_FILE_SIZE,
|
||||
} from "../constants";
|
||||
@@ -412,21 +413,43 @@ export function useKnowledgeBaseForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
const processSelectedFiles = (selectedFiles: FileList | null) => {
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...Array.from(selectedFiles)]);
|
||||
setIsFilePanelOpen(true);
|
||||
const allFiles = Array.from(selectedFiles);
|
||||
const filteredFiles: File[] = [];
|
||||
const excludedFiles: string[] = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
if (extension && KB_INGEST_EXTENSIONS.includes(extension)) {
|
||||
filteredFiles.push(file);
|
||||
} else {
|
||||
excludedFiles.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...filteredFiles]);
|
||||
setIsFilePanelOpen(true);
|
||||
}
|
||||
|
||||
if (excludedFiles.length > 0) {
|
||||
setErrorData({
|
||||
title:
|
||||
"Some files were skipped. Only supported file types were uploaded. Excluded files:",
|
||||
list: excludedFiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
processSelectedFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleFolderSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...Array.from(selectedFiles)]);
|
||||
setIsFilePanelOpen(true);
|
||||
}
|
||||
processSelectedFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ProviderList from "@/modals/modelProviderModal/components/ProviderList";
|
||||
import { Provider } from "@/modals/modelProviderModal/components/types";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { useProviderConfiguration } from "../hooks/useProviderConfiguration";
|
||||
import ModelSelection from "./ModelSelection";
|
||||
import ProviderConfigurationForm from "./ProviderConfigurationForm";
|
||||
import { useProviderConfiguration } from "../hooks/useProviderConfiguration";
|
||||
|
||||
interface ModelProvidersContentProps {
|
||||
modelType: "llm" | "embeddings" | "all";
|
||||
onFlushRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
const ModelProvidersContent = ({ modelType }: ModelProvidersContentProps) => {
|
||||
const ModelProvidersContent = ({
|
||||
modelType,
|
||||
onFlushRef,
|
||||
}: ModelProvidersContentProps) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(
|
||||
null,
|
||||
);
|
||||
@@ -36,11 +40,24 @@ const ModelProvidersContent = ({ modelType }: ModelProvidersContentProps) => {
|
||||
providerVariables,
|
||||
syncedSelectedProvider,
|
||||
handleModelToggle,
|
||||
flushPendingChanges,
|
||||
requiresConfiguration,
|
||||
} = useProviderConfiguration({
|
||||
selectedProvider,
|
||||
});
|
||||
|
||||
// Expose flushPendingChanges to the parent (ModelProviderModal) via ref
|
||||
useEffect(() => {
|
||||
if (onFlushRef) {
|
||||
onFlushRef.current = flushPendingChanges;
|
||||
}
|
||||
return () => {
|
||||
if (onFlushRef) {
|
||||
onFlushRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onFlushRef, flushPendingChanges]);
|
||||
|
||||
const handleProviderSelect = (provider: Provider) => {
|
||||
setSelectedProvider((prev) =>
|
||||
prev?.provider === provider.provider ? null : provider,
|
||||
@@ -51,7 +68,7 @@ const ModelProvidersContent = ({ modelType }: ModelProvidersContentProps) => {
|
||||
<div className="flex flex-row w-full h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex p-2 flex-col transition-all duration-300 ease-in-out",
|
||||
"flex p-2 flex-col flex-shrink-0 transition-all duration-300 ease-in-out",
|
||||
syncedSelectedProvider ? "w-1/3 border-r" : "w-full",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PROVIDER_VARIABLE_MAPPING,
|
||||
ProviderVariable,
|
||||
VARIABLE_CATEGORY,
|
||||
} from "@/constants/providerConstants";
|
||||
import { EnabledModelsResponse } from "@/controllers/API/queries/models/use-get-enabled-models";
|
||||
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
|
||||
import { useGetProviderVariables } from "@/controllers/API/queries/models/use-get-provider-variables";
|
||||
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
|
||||
import { useValidateProvider } from "@/controllers/API/queries/models/use-validate-provider";
|
||||
import {
|
||||
useDeleteGlobalVariables,
|
||||
useGetGlobalVariables,
|
||||
usePatchGlobalVariables,
|
||||
usePostGlobalVariables,
|
||||
} from "@/controllers/API/queries/variables";
|
||||
import { useValidateProvider } from "@/controllers/API/queries/models/use-validate-provider";
|
||||
import { useGetProviderVariables } from "@/controllers/API/queries/models/use-get-provider-variables";
|
||||
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
|
||||
import { EnabledModelsResponse } from "@/controllers/API/queries/models/use-get-enabled-models";
|
||||
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { Provider } from "../components/types";
|
||||
|
||||
@@ -48,6 +49,7 @@ interface UseProviderConfigurationReturn {
|
||||
handleActivateProvider: () => void;
|
||||
validateCredentials: () => Promise<boolean>;
|
||||
handleModelToggle: (modelName: string, enabled: boolean) => void;
|
||||
flushPendingChanges: () => Promise<void>;
|
||||
|
||||
// Helpers
|
||||
isVariableConfigured: (key: string) => boolean;
|
||||
@@ -98,7 +100,9 @@ export const useProviderConfiguration = ({
|
||||
const { data: globalVariables = [] } = useGetGlobalVariables();
|
||||
const { mutateAsync: validateProvider } = useValidateProvider();
|
||||
const { data: providerVariablesMapping = {} } = useGetProviderVariables();
|
||||
const { mutate: updateEnabledModels } = useUpdateEnabledModels({ retry: 0 });
|
||||
const { mutate: updateEnabledModels, mutateAsync: updateEnabledModelsAsync } =
|
||||
useUpdateEnabledModels({ retry: 0 });
|
||||
const { refreshAllModelInputs } = useRefreshModelInputs();
|
||||
const { data: modelProviders = [], isFetching: isFetchingModels } =
|
||||
useGetModelProviders(
|
||||
{},
|
||||
@@ -144,12 +148,21 @@ export const useProviderConfiguration = ({
|
||||
setSuccessData({ title: pendingSuccessTitleRef.current });
|
||||
pendingSuccessTitleRef.current = null;
|
||||
}
|
||||
// Refresh all model nodes on the canvas so they pick up new models
|
||||
refreshAllModelInputs({ silent: true });
|
||||
}
|
||||
if (isFetchingAfterDisconnect) {
|
||||
setIsFetchingAfterDisconnect(false);
|
||||
// Refresh all model nodes on the canvas so they reflect the disconnect
|
||||
refreshAllModelInputs({ silent: true });
|
||||
}
|
||||
}
|
||||
}, [isFetchingModels, isFetchingAfterSave, isFetchingAfterDisconnect]);
|
||||
}, [
|
||||
isFetchingModels,
|
||||
isFetchingAfterSave,
|
||||
isFetchingAfterDisconnect,
|
||||
refreshAllModelInputs,
|
||||
]);
|
||||
|
||||
// Keep syncedSelectedProvider in sync with prop and reset state on provider change
|
||||
useEffect(() => {
|
||||
@@ -201,7 +214,7 @@ export const useProviderConfiguration = ({
|
||||
|
||||
const providerName = syncedSelectedProvider.provider;
|
||||
const apiVariables = providerVariablesMapping[providerName];
|
||||
if (apiVariables && apiVariables.length > 0) {
|
||||
if (Array.isArray(apiVariables) && apiVariables.length > 0) {
|
||||
return apiVariables;
|
||||
}
|
||||
|
||||
@@ -583,11 +596,60 @@ export const useProviderConfiguration = ({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetModelProviders"],
|
||||
});
|
||||
refreshAllModelInputs({ silent: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
const flushPendingChanges = useCallback(async () => {
|
||||
// Cancel the pending debounce timer — we'll send the toggles directly
|
||||
flushModelToggles.cancel();
|
||||
|
||||
if (!syncedSelectedProvider?.provider) return;
|
||||
const providerName = syncedSelectedProvider.provider;
|
||||
|
||||
const toggles = { ...pendingModelToggles.current };
|
||||
if (Object.keys(toggles).length === 0) return;
|
||||
|
||||
const updates = Object.entries(toggles).map(([modelName, enabled]) => ({
|
||||
provider: providerName,
|
||||
model_id: modelName,
|
||||
enabled,
|
||||
}));
|
||||
|
||||
const previousData = fallbackModelData.current;
|
||||
|
||||
// Clear buffer
|
||||
pendingModelToggles.current = {};
|
||||
fallbackModelData.current = undefined;
|
||||
|
||||
try {
|
||||
await updateEnabledModelsAsync({ updates });
|
||||
// Mutation succeeded — query invalidation is handled by
|
||||
// refreshAllModelInputs which runs after this promise resolves.
|
||||
} catch (error: any) {
|
||||
// Revert optimistic update on failure
|
||||
if (previousData) {
|
||||
queryClient.setQueryData(["useGetEnabledModels"], previousData);
|
||||
}
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail ||
|
||||
error?.message ||
|
||||
"Failed to update model status";
|
||||
setErrorData({
|
||||
title: "Error updating model status",
|
||||
list: [errorMessage],
|
||||
});
|
||||
}
|
||||
}, [
|
||||
flushModelToggles,
|
||||
syncedSelectedProvider,
|
||||
queryClient,
|
||||
updateEnabledModelsAsync,
|
||||
setErrorData,
|
||||
]);
|
||||
|
||||
const handleModelToggle = useCallback(
|
||||
(modelName: string, enabled: boolean) => {
|
||||
if (!syncedSelectedProvider?.provider) return;
|
||||
@@ -642,6 +704,7 @@ export const useProviderConfiguration = ({
|
||||
handleActivateProvider,
|
||||
validateCredentials,
|
||||
handleModelToggle,
|
||||
flushPendingChanges,
|
||||
|
||||
// Helpers
|
||||
isVariableConfigured,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
|
||||
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
|
||||
import ModelProvidersContent from "./components/ModelProvidersContent";
|
||||
@@ -14,12 +15,16 @@ const ModelProviderModal = ({
|
||||
modelType,
|
||||
}: ModelProviderModalProps) => {
|
||||
const { refreshAllModelInputs } = useRefreshModelInputs();
|
||||
const flushRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = async () => {
|
||||
// Capture the flush promise BEFORE onClose unmounts the modal content.
|
||||
// flushPendingChanges sends any pending model toggle mutations via
|
||||
// mutateAsync and awaits the backend response, so the DB is up-to-date
|
||||
// by the time we refresh nodes below.
|
||||
const flushPromise = flushRef.current?.();
|
||||
onClose();
|
||||
// Refresh model inputs to pick up any enabled/disabled changes
|
||||
// Note: The mutations in ModelProvidersContent already invalidate queries on success,
|
||||
// so this refresh primarily re-fetches the template options for nodes.
|
||||
await flushPromise;
|
||||
refreshAllModelInputs({ silent: true });
|
||||
};
|
||||
|
||||
@@ -33,7 +38,7 @@ const ModelProviderModal = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-[513px] overflow-hidden">
|
||||
<ModelProvidersContent modelType={modelType} />
|
||||
<ModelProvidersContent modelType={modelType} onFlushRef={flushRef} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function StepperModal({
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-hidden px-4 py-4 border border-border m-4 rounded-lg",
|
||||
"flex-1 min-h-0 overflow-y-auto px-4 py-4 border border-border m-4 rounded-lg",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -351,10 +351,11 @@ export function FlowInsightsContent({
|
||||
>
|
||||
<DialogContent
|
||||
className={
|
||||
"right-0 top-0 h-[100dvh] w-full max-w-none rounded-l-xl rounded-r-none p-0 sm:w-[70vw] " +
|
||||
"right-0 top-[3rem] h-[calc(100dvh-3rem)] w-full max-w-none rounded-l-xl rounded-r-none p-0 sm:w-[80vw] " +
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out " +
|
||||
"data-[state=open]:slide-in-from-right-1/2 data-[state=closed]:slide-out-to-right-1/2"
|
||||
}
|
||||
closeButtonClassName="top-1"
|
||||
data-testid="flow-insights-trace-panel"
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
@@ -31,10 +31,10 @@ export function SpanDetail({ span }: SpanDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const hasInputs = Object.keys(span.inputs).length > 0;
|
||||
const hasOutputs = Object.keys(span.outputs).length > 0;
|
||||
const hasTokenUsage = span.tokenUsage && span.tokenUsage.totalTokens > 0;
|
||||
const isLlmSpan = span.type === "llm";
|
||||
const hasInputs = Object.keys(span?.inputs || {}).length > 0;
|
||||
const hasOutputs = Object.keys(span?.outputs || {}).length > 0;
|
||||
const hasTokenUsage = span?.tokenUsage && span.tokenUsage.totalTokens > 0;
|
||||
const isLlmSpan = span?.type === "llm";
|
||||
|
||||
const { colorClass, iconName, shouldSpin } = getStatusIconProps(span.status);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { cn } from "@/utils/utils";
|
||||
import {
|
||||
formatTokens,
|
||||
formatTotalLatency,
|
||||
getSpanIcon,
|
||||
getStatusIconProps,
|
||||
getStatusVariant,
|
||||
} from "./traceViewHelpers";
|
||||
import { SpanNodeProps } from "./types";
|
||||
|
||||
@@ -22,6 +22,23 @@ export function SpanNode({
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: SpanNodeProps) {
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const componentIconMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
nodes.forEach((node) => {
|
||||
const nodeData = node.data?.node;
|
||||
const displayName = nodeData?.display_name;
|
||||
const icon = nodeData && "icon" in nodeData ? nodeData.icon : undefined;
|
||||
if (displayName && icon) {
|
||||
map.set(displayName.toLowerCase(), icon);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
|
||||
const spanIconName = span.name
|
||||
? (componentIconMap.get(span.name.toLowerCase()) ?? getSpanIcon(span.type))
|
||||
: getSpanIcon(span.type);
|
||||
const hasChildren = span.children.length > 0;
|
||||
const tokenStr = formatTokens(span.tokenUsage?.totalTokens);
|
||||
|
||||
@@ -70,7 +87,7 @@ export function SpanNode({
|
||||
span.status === "unset" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<IconComponent name={getSpanIcon(span.type)} className="h-4 w-4" />
|
||||
<IconComponent name={spanIconName} className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Span name */}
|
||||
@@ -97,19 +114,14 @@ export function SpanNode({
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
variant={getStatusVariant(span.status)}
|
||||
size="xq"
|
||||
className="min-w-[16px]"
|
||||
>
|
||||
<IconComponent
|
||||
name={iconName}
|
||||
className={`h-4 w-4 ${colorClass} ${shouldSpin ? "animate-spin" : ""}`}
|
||||
aria-label={span.status}
|
||||
dataTestId={`flow-log-status-${span.status}`}
|
||||
skipFallback
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<IconComponent
|
||||
name={iconName}
|
||||
className={`h-4 w-4 ${colorClass} ${shouldSpin ? "animate-spin" : ""}`}
|
||||
aria-label={span.status}
|
||||
dataTestId={`flow-log-status-${span.status}`}
|
||||
skipFallback
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Loading from "@/components/ui/loading";
|
||||
import { useGetTraceQuery } from "@/controllers/API/queries/traces";
|
||||
import { SpanDetail } from "./SpanDetail";
|
||||
import { SpanTree } from "./SpanTree";
|
||||
import { formatTotalLatency } from "./traceViewHelpers";
|
||||
import { Span, TraceDetailViewProps } from "./types";
|
||||
|
||||
/**
|
||||
@@ -28,12 +25,7 @@ export function TraceDetailView({ traceId, flowName }: TraceDetailViewProps) {
|
||||
if (!trace) return null;
|
||||
|
||||
const status = trace.status;
|
||||
const name =
|
||||
status === "ok"
|
||||
? "Successful Run"
|
||||
: status === "error"
|
||||
? "Failed Run"
|
||||
: "Run Summary";
|
||||
const name = trace.name || flowName || "Run Summary";
|
||||
|
||||
return {
|
||||
id: trace.id,
|
||||
@@ -120,40 +112,13 @@ export function TraceDetailView({ traceId, flowName }: TraceDetailViewProps) {
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 text-sm font-medium">Trace Details</span>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">—</span>
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{headerTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="max-w-[280px] truncate font-mono text-xs"
|
||||
title={trace.id}
|
||||
>
|
||||
<IconComponent name="Hash" className="mr-1 h-3 w-3" />
|
||||
{trace.id}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<IconComponent name="Clock" className="h-3 w-3" />
|
||||
{formatTotalLatency(trace.totalLatencyMs)}
|
||||
</span>
|
||||
{trace.totalTokens > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconComponent name="Coins" className="h-3 w-3" />
|
||||
{trace.totalTokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-sm font-medium">{trace.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-[320px] min-w-[280px] overflow-y-auto border-r border-border p-2">
|
||||
<div className="w-[380px] min-w-[320px] overflow-y-auto border-r border-border p-2">
|
||||
<SpanTree
|
||||
spans={treeSpans}
|
||||
selectedSpanId={selectedSpan?.id ?? null}
|
||||
|
||||
@@ -85,9 +85,7 @@ describe("TraceDetailView", () => {
|
||||
// Summary node should render as the root.
|
||||
expect(screen.getByTestId("span-node-trace-1")).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId("span-node-trace-1")).getByText(
|
||||
"Successful Run",
|
||||
),
|
||||
within(screen.getByTestId("span-node-trace-1")).getByText("My Trace"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Child span should render under it by default.
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("traceViewHelpers", () => {
|
||||
expect(getSpanIcon("agent")).toBe("Bot");
|
||||
expect(getSpanIcon("chain")).toBe("Link");
|
||||
expect(getSpanIcon("retriever")).toBe("Search");
|
||||
expect(getSpanIcon("none")).toBe("");
|
||||
expect(getSpanIcon("none")).toBe("Workflow");
|
||||
});
|
||||
|
||||
it("falls back to Circle for unknown types", () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const getSpanIcon = (type: SpanType): string => {
|
||||
retriever: "Search",
|
||||
embedding: "Hash",
|
||||
parser: "FileText",
|
||||
none: "",
|
||||
none: "Workflow",
|
||||
};
|
||||
const icon = iconMap[type];
|
||||
return icon === undefined ? "Circle" : icon;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { disableItem } from "../disable-item";
|
||||
import type { UniqueInputsComponents } from "../../types";
|
||||
|
||||
describe("disableItem", () => {
|
||||
describe("ChatInput component", () => {
|
||||
it("should disable ChatInput when ChatInput already exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(disableItem("ChatInput", uniqueInputs)).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable ChatInput when Webhook exists (mutual exclusivity)", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(disableItem("ChatInput", uniqueInputs)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not disable ChatInput when neither exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(disableItem("ChatInput", uniqueInputs)).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable ChatInput when both exist (edge case)", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(disableItem("ChatInput", uniqueInputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook component", () => {
|
||||
it("should disable Webhook when Webhook already exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(disableItem("Webhook", uniqueInputs)).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable Webhook when ChatInput exists (mutual exclusivity)", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(disableItem("Webhook", uniqueInputs)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not disable Webhook when neither exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(disableItem("Webhook", uniqueInputs)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Other components", () => {
|
||||
it("should not disable other components when both ChatInput and Webhook exist", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(disableItem("SomeOtherComponent", uniqueInputs)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not disable other components when only ChatInput exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(disableItem("TextInput", uniqueInputs)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not disable other components when only Webhook exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(disableItem("TextInput", uniqueInputs)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getDisabledTooltip } from "../get-disabled-tooltip";
|
||||
import type { UniqueInputsComponents } from "../../types";
|
||||
|
||||
describe("getDisabledTooltip", () => {
|
||||
describe("ChatInput component", () => {
|
||||
it("should return tooltip when ChatInput already exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("ChatInput", uniqueInputs)).toBe(
|
||||
"Chat input already added",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return tooltip when trying to add ChatInput while Webhook exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("ChatInput", uniqueInputs)).toBe(
|
||||
"Cannot add Chat Input when Webhook is present",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty string when ChatInput can be added", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("ChatInput", uniqueInputs)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook component", () => {
|
||||
it("should return tooltip when Webhook already exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("Webhook", uniqueInputs)).toBe(
|
||||
"Webhook already added",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return tooltip when trying to add Webhook while ChatInput exists", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("Webhook", uniqueInputs)).toBe(
|
||||
"Cannot add Webhook when Chat Input is present",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty string when Webhook can be added", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: false,
|
||||
webhookInput: false,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("Webhook", uniqueInputs)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Other components", () => {
|
||||
it("should return empty string for other components", () => {
|
||||
const uniqueInputs: UniqueInputsComponents = {
|
||||
chatInput: true,
|
||||
webhookInput: true,
|
||||
};
|
||||
|
||||
expect(getDisabledTooltip("SomeOtherComponent", uniqueInputs)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Component name constants
|
||||
export const CHAT_INPUT_COMPONENT = "ChatInput";
|
||||
export const WEBHOOK_COMPONENT = "Webhook";
|
||||
|
||||
// Exclusivity rules: components that cannot coexist
|
||||
export const EXCLUSIVITY_RULES = {
|
||||
[CHAT_INPUT_COMPONENT]: [WEBHOOK_COMPONENT],
|
||||
[WEBHOOK_COMPONENT]: [CHAT_INPUT_COMPONENT],
|
||||
} as const;
|
||||
|
||||
// Tooltip messages
|
||||
export const TOOLTIP_MESSAGES = {
|
||||
CHAT_INPUT_ALREADY_ADDED: "Chat input already added",
|
||||
WEBHOOK_ALREADY_ADDED: "Webhook already added",
|
||||
CANNOT_ADD_CHAT_INPUT_WITH_WEBHOOK:
|
||||
"Cannot add Chat Input when Webhook is present",
|
||||
CANNOT_ADD_WEBHOOK_WITH_CHAT_INPUT:
|
||||
"Cannot add Webhook when Chat Input is present",
|
||||
} as const;
|
||||
@@ -1,14 +1,40 @@
|
||||
import type { UniqueInputsComponents } from "../types";
|
||||
import {
|
||||
CHAT_INPUT_COMPONENT,
|
||||
EXCLUSIVITY_RULES,
|
||||
WEBHOOK_COMPONENT,
|
||||
} from "./constants";
|
||||
|
||||
export const disableItem = (
|
||||
SBItemName: string,
|
||||
uniqueInputsComponents: UniqueInputsComponents,
|
||||
) => {
|
||||
if (SBItemName === "ChatInput" && uniqueInputsComponents.chatInput) {
|
||||
// Check if component already exists
|
||||
if (SBItemName === CHAT_INPUT_COMPONENT && uniqueInputsComponents.chatInput) {
|
||||
return true;
|
||||
}
|
||||
if (SBItemName === "Webhook" && uniqueInputsComponents.webhookInput) {
|
||||
if (SBItemName === WEBHOOK_COMPONENT && uniqueInputsComponents.webhookInput) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check exclusivity rules
|
||||
const exclusiveComponents = EXCLUSIVITY_RULES[SBItemName];
|
||||
if (exclusiveComponents) {
|
||||
for (const exclusiveComponent of exclusiveComponents) {
|
||||
if (
|
||||
exclusiveComponent === CHAT_INPUT_COMPONENT &&
|
||||
uniqueInputsComponents.chatInput
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
exclusiveComponent === WEBHOOK_COMPONENT &&
|
||||
uniqueInputsComponents.webhookInput
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import type { UniqueInputsComponents } from "../types";
|
||||
import {
|
||||
CHAT_INPUT_COMPONENT,
|
||||
TOOLTIP_MESSAGES,
|
||||
WEBHOOK_COMPONENT,
|
||||
} from "./constants";
|
||||
|
||||
export const getDisabledTooltip = (
|
||||
SBItemName: string,
|
||||
uniqueInputsComponents: UniqueInputsComponents,
|
||||
) => {
|
||||
if (SBItemName === "ChatInput" && uniqueInputsComponents.chatInput) {
|
||||
return "Chat input already added";
|
||||
if (SBItemName === CHAT_INPUT_COMPONENT && uniqueInputsComponents.chatInput) {
|
||||
return TOOLTIP_MESSAGES.CHAT_INPUT_ALREADY_ADDED;
|
||||
}
|
||||
if (SBItemName === "Webhook" && uniqueInputsComponents.webhookInput) {
|
||||
return "Webhook already added";
|
||||
if (
|
||||
SBItemName === CHAT_INPUT_COMPONENT &&
|
||||
uniqueInputsComponents.webhookInput
|
||||
) {
|
||||
return TOOLTIP_MESSAGES.CANNOT_ADD_CHAT_INPUT_WITH_WEBHOOK;
|
||||
}
|
||||
if (SBItemName === WEBHOOK_COMPONENT && uniqueInputsComponents.webhookInput) {
|
||||
return TOOLTIP_MESSAGES.WEBHOOK_ALREADY_ADDED;
|
||||
}
|
||||
if (SBItemName === WEBHOOK_COMPONENT && uniqueInputsComponents.chatInput) {
|
||||
return TOOLTIP_MESSAGES.CANNOT_ADD_WEBHOOK_WITH_CHAT_INPUT;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -160,10 +160,7 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
const knowledgeComponentNames = [
|
||||
"KnowledgeIngestion",
|
||||
"KnowledgeRetrieval",
|
||||
];
|
||||
const knowledgeComponentNames = ["KnowledgeBase"];
|
||||
|
||||
// Create a deep copy to avoid mutating the original
|
||||
const filteredData = cloneDeep(rawData);
|
||||
|
||||
@@ -38,10 +38,9 @@ const useFileDrop = (type?: string) => {
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setErrorData({
|
||||
title: CONSOLE_ERROR_MSG,
|
||||
list: [(error as Error).message],
|
||||
list: [error instanceof Error ? error.message : String(error)],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Loading from "@/components/ui/loading";
|
||||
import KnowledgeBaseUploadModal from "@/modals/knowledgeBaseUploadModal/KnowledgeBaseUploadModal";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { useOptimisticKnowledgeBase } from "../hooks/useOptimisticKnowledgeBase";
|
||||
|
||||
const KnowledgeBaseEmptyState = ({
|
||||
handleCreateKnowledge,
|
||||
@@ -12,20 +11,8 @@ const KnowledgeBaseEmptyState = ({
|
||||
handleCreateKnowledge: () => void;
|
||||
}) => {
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (isCreating) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
<Loading size={36} />
|
||||
<span className="text-sm text-muted-foreground pt-3">
|
||||
Setting up your knowledge base...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { captureSubmit, applyOptimisticUpdate } = useOptimisticKnowledgeBase();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-8 pb-8">
|
||||
@@ -51,13 +38,11 @@ const KnowledgeBaseEmptyState = ({
|
||||
setOpen={(open) => {
|
||||
setIsUploadModalOpen(open);
|
||||
if (!open) {
|
||||
setIsCreating(true);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["useGetKnowledgeBases"],
|
||||
});
|
||||
applyOptimisticUpdate();
|
||||
}
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
captureSubmit(data);
|
||||
setSuccessData({
|
||||
title: `Knowledge base "${data.sourceName}" created`,
|
||||
});
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import KnowledgeBaseEmptyState from "../KnowledgeBaseEmptyState";
|
||||
|
||||
// Mock all the dependencies to avoid complex imports
|
||||
jest.mock("@/stores/flowsManagerStore", () => ({
|
||||
// Mock dependencies
|
||||
jest.mock("@/stores/alertStore", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
default: jest.fn((selector) =>
|
||||
selector({
|
||||
setSuccessData: jest.fn(),
|
||||
setErrorData: jest.fn(),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/flows/use-add-flow", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
const mockCaptureSubmit = jest.fn();
|
||||
const mockApplyOptimisticUpdate = jest.fn().mockReturnValue(true);
|
||||
|
||||
jest.mock("../../hooks/useOptimisticKnowledgeBase", () => ({
|
||||
useOptimisticKnowledgeBase: () => ({
|
||||
captureSubmit: mockCaptureSubmit,
|
||||
applyOptimisticUpdate: mockApplyOptimisticUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/hooks/use-custom-navigate", () => ({
|
||||
useCustomNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/foldersStore", () => ({
|
||||
useFolderStore: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/utils/analytics", () => ({
|
||||
track: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/reactflowUtils", () => ({
|
||||
updateIds: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the component itself to test in isolation
|
||||
jest.mock("../KnowledgeBaseEmptyState", () => {
|
||||
const MockKnowledgeBaseEmptyState = () => (
|
||||
<div data-testid="knowledge-base-empty-state">
|
||||
<h3>No knowledge bases</h3>
|
||||
<p>Create your first knowledge base to get started.</p>
|
||||
<button data-testid="create-knowledge-btn">Create Knowledge</button>
|
||||
</div>
|
||||
);
|
||||
MockKnowledgeBaseEmptyState.displayName = "KnowledgeBaseEmptyState";
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockKnowledgeBaseEmptyState,
|
||||
// Mock the modal component
|
||||
jest.mock("@/modals/knowledgeBaseUploadModal/KnowledgeBaseUploadModal", () => {
|
||||
return function MockKnowledgeBaseUploadModal({
|
||||
open,
|
||||
setOpen,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
return open ? (
|
||||
<div data-testid="upload-modal">
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-submit"
|
||||
onClick={() => {
|
||||
onSubmit({
|
||||
sourceName: "TestKB",
|
||||
files: [new File(["content"], "test.txt")],
|
||||
embeddingModel: null,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
});
|
||||
|
||||
const KnowledgeBaseEmptyState = require("../KnowledgeBaseEmptyState").default;
|
||||
jest.mock("@/components/common/genericIconComponent", () => {
|
||||
return function MockIcon() {
|
||||
return <span data-testid="mock-icon" />;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const createTestWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -57,49 +81,114 @@ const createTestWrapper = () => {
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("KnowledgeBaseEmptyState", () => {
|
||||
const mockHandleCreateKnowledge = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state message correctly", () => {
|
||||
render(<KnowledgeBaseEmptyState />, { wrapper: createTestWrapper() });
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
expect(screen.getByText("No knowledge bases")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Create your first knowledge base to get started."),
|
||||
screen.getByText(/Create powerful AI experiences/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders create knowledge button", () => {
|
||||
render(<KnowledgeBaseEmptyState />, { wrapper: createTestWrapper() });
|
||||
it("renders Add Knowledge button", () => {
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
const createButton = screen.getByTestId("create-knowledge-btn");
|
||||
expect(createButton).toBeInTheDocument();
|
||||
expect(createButton).toHaveTextContent("Create Knowledge");
|
||||
const addButton = screen.getByText("Add Knowledge");
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles create knowledge button click", () => {
|
||||
render(<KnowledgeBaseEmptyState />, { wrapper: createTestWrapper() });
|
||||
it("opens modal when Add Knowledge button is clicked", () => {
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
const createButton = screen.getByTestId("create-knowledge-btn");
|
||||
fireEvent.click(createButton);
|
||||
const addButton = screen.getByText("Add Knowledge");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Since we're using a mock, we just verify the button is clickable
|
||||
expect(createButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId("upload-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with correct test id", () => {
|
||||
render(<KnowledgeBaseEmptyState />, { wrapper: createTestWrapper() });
|
||||
it("calls captureSubmit when form is submitted", () => {
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("knowledge-base-empty-state"),
|
||||
).toBeInTheDocument();
|
||||
const addButton = screen.getByText("Add Knowledge");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const submitButton = screen.getByTestId("modal-submit");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(mockCaptureSubmit).toHaveBeenCalledWith({
|
||||
sourceName: "TestKB",
|
||||
files: expect.any(Array),
|
||||
embeddingModel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls applyOptimisticUpdate when modal closes after submission", () => {
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
const addButton = screen.getByText("Add Knowledge");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const submitButton = screen.getByTestId("modal-submit");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(mockApplyOptimisticUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal without calling applyOptimisticUpdate when closed without submission", () => {
|
||||
mockApplyOptimisticUpdate.mockClear();
|
||||
|
||||
render(
|
||||
<KnowledgeBaseEmptyState
|
||||
handleCreateKnowledge={mockHandleCreateKnowledge}
|
||||
/>,
|
||||
{ wrapper: createTestWrapper() },
|
||||
);
|
||||
|
||||
const addButton = screen.getByText("Add Knowledge");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("upload-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByTestId("modal-close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Modal should call applyOptimisticUpdate even on close (it returns false if no submission)
|
||||
expect(mockApplyOptimisticUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ export const SourceChunksPage = () => {
|
||||
className="flex h-full w-full flex-col"
|
||||
data-testid="source-chunks-wrapper"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-10">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-10 px-5">
|
||||
<div
|
||||
className="flex shrink-0 items-center pb-4 text-base h-[44px] font-semibold"
|
||||
data-testid="mainpage_title"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { toCamelCase, toTitleCase } from "@/utils/utils";
|
||||
|
||||
type ShortcutItem = {
|
||||
name: string;
|
||||
shortcut: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
export function findShortcutByName(
|
||||
shortcuts: ShortcutItem[],
|
||||
shortcutName: string,
|
||||
): ShortcutItem | undefined {
|
||||
return shortcuts.find(
|
||||
(shortcut) =>
|
||||
toCamelCase(shortcut.name) === toCamelCase(shortcutName ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function isDuplicateCombination(
|
||||
shortcuts: ShortcutItem[],
|
||||
currentName: string,
|
||||
newCombination: string,
|
||||
): boolean {
|
||||
return shortcuts.some(
|
||||
(existing) =>
|
||||
existing.name !== currentName &&
|
||||
existing.shortcut.toLowerCase() === newCombination.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFixedCombination(
|
||||
oldKey: string | null,
|
||||
key: string,
|
||||
): string {
|
||||
if (oldKey === null) {
|
||||
return `${key.length > 0 ? toTitleCase(key) : toTitleCase(key)}`;
|
||||
}
|
||||
return `${
|
||||
oldKey.length > 0 ? toTitleCase(oldKey) : oldKey.toUpperCase()
|
||||
} + ${key.length > 0 ? toTitleCase(key) : key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function checkForKeys(keys: string, keyToCompare: string): boolean {
|
||||
const keysArr = keys.split(" ");
|
||||
return keysArr.some(
|
||||
(k) => k.toLowerCase().trim() === keyToCompare.toLowerCase().trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeRecordedCombination(recorded: string): string {
|
||||
const parts = recorded.split(" ");
|
||||
if (
|
||||
parts[0]?.toLowerCase().includes("ctrl") ||
|
||||
parts[0]?.toLowerCase().includes("cmd")
|
||||
) {
|
||||
parts[0] = "mod";
|
||||
}
|
||||
return parts.join("").toLowerCase();
|
||||
}
|
||||
@@ -5,11 +5,19 @@ import { Button } from "../../../../../components/ui/button";
|
||||
import BaseModal from "../../../../../modals/baseModal";
|
||||
import useAlertStore from "../../../../../stores/alertStore";
|
||||
import { useShortcutsStore } from "../../../../../stores/shortcuts";
|
||||
import { toCamelCase, toTitleCase } from "../../../../../utils/utils";
|
||||
import { toCamelCase } from "../../../../../utils/utils";
|
||||
import {
|
||||
checkForKeys,
|
||||
findShortcutByName,
|
||||
getFixedCombination,
|
||||
isDuplicateCombination,
|
||||
normalizeRecordedCombination,
|
||||
} from "./helpers";
|
||||
|
||||
export default function EditShortcutButton({
|
||||
children,
|
||||
shortcut,
|
||||
shortcuts,
|
||||
defaultShortcuts,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -18,6 +26,11 @@ export default function EditShortcutButton({
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
shortcut: string[];
|
||||
shortcuts: Array<{
|
||||
name: string;
|
||||
shortcut: string;
|
||||
display_name: string;
|
||||
}>;
|
||||
defaultShortcuts: Array<{
|
||||
name: string;
|
||||
shortcut: string;
|
||||
@@ -28,74 +41,65 @@ export default function EditShortcutButton({
|
||||
disable?: boolean;
|
||||
setSelected: (selected: string[]) => void;
|
||||
}): JSX.Element {
|
||||
const shortcutInitialValue =
|
||||
defaultShortcuts.length > 0
|
||||
? defaultShortcuts.find(
|
||||
(s) => toCamelCase(s.name) === toCamelCase(shortcut[0]),
|
||||
)?.shortcut
|
||||
: "";
|
||||
const shortcutInitialValue = findShortcutByName(
|
||||
shortcuts,
|
||||
shortcut[0],
|
||||
)?.shortcut;
|
||||
const [key, setKey] = useState<string | null>(null);
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
const setShortcuts = useShortcutsStore((state) => state.setShortcuts);
|
||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||
|
||||
function canEditCombination(newCombination: string): boolean {
|
||||
let canSave = true;
|
||||
defaultShortcuts.forEach(({ shortcut }) => {
|
||||
if (shortcut.toLowerCase() === newCombination.toLowerCase()) {
|
||||
canSave = false;
|
||||
}
|
||||
});
|
||||
return canSave;
|
||||
}
|
||||
|
||||
const setUniqueShortcut = useShortcutsStore(
|
||||
(state) => state.updateUniqueShortcut,
|
||||
);
|
||||
|
||||
function editCombination(): void {
|
||||
if (key) {
|
||||
if (canEditCombination(key)) {
|
||||
const fixCombination = key.split(" ");
|
||||
if (
|
||||
fixCombination[0].toLowerCase().includes("ctrl") ||
|
||||
fixCombination[0].toLowerCase().includes("cmd")
|
||||
) {
|
||||
fixCombination[0] = "mod";
|
||||
}
|
||||
const newCombination = defaultShortcuts.map((s) => {
|
||||
if (s.name === shortcut[0]) {
|
||||
return {
|
||||
name: s.name,
|
||||
display_name: s.display_name,
|
||||
shortcut: fixCombination.join("").toLowerCase(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: s.name,
|
||||
display_name: s.display_name,
|
||||
shortcut: s.shortcut,
|
||||
};
|
||||
});
|
||||
const shortcutName = toCamelCase(shortcut[0]);
|
||||
setUniqueShortcut(shortcutName, fixCombination.join("").toLowerCase());
|
||||
setShortcuts(newCombination);
|
||||
localStorage.setItem(
|
||||
"langflow-shortcuts",
|
||||
JSON.stringify(newCombination),
|
||||
);
|
||||
setKey(null);
|
||||
setOpen(false);
|
||||
setSuccessData({
|
||||
title: `${shortcut[0]} shortcut successfully changed`,
|
||||
});
|
||||
return;
|
||||
function applyShortcutUpdate(newCombination: string, successTitle: string) {
|
||||
const nextShortcuts = shortcuts.map((s) => {
|
||||
if (s.name === shortcut[0]) {
|
||||
return {
|
||||
name: s.name,
|
||||
display_name: s.display_name,
|
||||
shortcut: newCombination,
|
||||
};
|
||||
}
|
||||
}
|
||||
setErrorData({
|
||||
title: "Error saving key combination",
|
||||
list: ["This combination already exists!"],
|
||||
return {
|
||||
name: s.name,
|
||||
display_name: s.display_name,
|
||||
shortcut: s.shortcut,
|
||||
};
|
||||
});
|
||||
const shortcutName = toCamelCase(shortcut[0]);
|
||||
setUniqueShortcut(shortcutName, newCombination);
|
||||
setShortcuts(nextShortcuts);
|
||||
localStorage.setItem("langflow-shortcuts", JSON.stringify(nextShortcuts));
|
||||
setKey(null);
|
||||
setOpen(false);
|
||||
setSuccessData({
|
||||
title: successTitle,
|
||||
});
|
||||
}
|
||||
|
||||
function editCombination(): void {
|
||||
if (!key) {
|
||||
setErrorData({
|
||||
title: "Error saving key combination",
|
||||
list: ["No key combination recorded."],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalizedCombination = normalizeRecordedCombination(key);
|
||||
if (isDuplicateCombination(shortcuts, shortcut[0], normalizedCombination)) {
|
||||
setErrorData({
|
||||
title: "Error saving key combination",
|
||||
list: ["This combination already exists!"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyShortcutUpdate(
|
||||
normalizedCombination,
|
||||
`${shortcut[0]} shortcut successfully changed`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,26 +109,28 @@ export default function EditShortcutButton({
|
||||
}
|
||||
}, [open, setOpen, key]);
|
||||
|
||||
function getFixedCombination({
|
||||
oldKey,
|
||||
key,
|
||||
}: {
|
||||
oldKey: string;
|
||||
key: string;
|
||||
}): string {
|
||||
if (oldKey === null) {
|
||||
return `${key.length > 0 ? toTitleCase(key) : toTitleCase(key)}`;
|
||||
function handleResetToDefault(): void {
|
||||
const defaultShortcut = findShortcutByName(
|
||||
defaultShortcuts,
|
||||
shortcut[0],
|
||||
)?.shortcut;
|
||||
if (!defaultShortcut) {
|
||||
setErrorData({
|
||||
title: "Error resetting shortcut",
|
||||
list: ["Default shortcut not found."],
|
||||
});
|
||||
return;
|
||||
}
|
||||
return `${
|
||||
oldKey.length > 0 ? toTitleCase(oldKey) : oldKey.toUpperCase()
|
||||
} + ${key.length > 0 ? toTitleCase(key) : key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function checkForKeys(keys: string, keyToCompare: string): boolean {
|
||||
const keysArr = keys.split(" ");
|
||||
const _hasNewKey = false;
|
||||
return keysArr.some(
|
||||
(k) => k.toLowerCase().trim() === keyToCompare.toLowerCase().trim(),
|
||||
if (isDuplicateCombination(shortcuts, shortcut[0], defaultShortcut)) {
|
||||
setErrorData({
|
||||
title: "Error resetting shortcut",
|
||||
list: ["This combination already exists!"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyShortcutUpdate(
|
||||
defaultShortcut,
|
||||
`${shortcut[0]} shortcut reset to default`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,9 +150,7 @@ export default function EditShortcutButton({
|
||||
if (key) {
|
||||
if (checkForKeys(key, fixedKey)) return;
|
||||
}
|
||||
setKey((oldKey) =>
|
||||
getFixedCombination({ oldKey: oldKey!, key: fixedKey }),
|
||||
);
|
||||
setKey((oldKey) => getFixedCombination(oldKey, fixedKey));
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
@@ -183,7 +187,7 @@ export default function EditShortcutButton({
|
||||
<Button
|
||||
className="mr-5"
|
||||
variant={"destructive"}
|
||||
onClick={() => setKey(null)}
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
checkForKeys,
|
||||
findShortcutByName,
|
||||
getFixedCombination,
|
||||
isDuplicateCombination,
|
||||
normalizeRecordedCombination,
|
||||
} from "../EditShortcutButton/helpers";
|
||||
|
||||
describe("EditShortcutButton helpers", () => {
|
||||
const shortcuts = [
|
||||
{ name: "Docs", display_name: "Docs", shortcut: "mod+shift+d" },
|
||||
{ name: "Code", display_name: "Code", shortcut: "mod+." },
|
||||
{ name: "Open Playground", display_name: "Playground", shortcut: "mod+k" },
|
||||
];
|
||||
|
||||
it("finds a shortcut by name", () => {
|
||||
const result = findShortcutByName(shortcuts, "open playground");
|
||||
expect(result?.shortcut).toBe("mod+k");
|
||||
});
|
||||
|
||||
it("detects duplicate combinations across shortcuts", () => {
|
||||
const hasDuplicate = isDuplicateCombination(shortcuts, "Code", "mod+k");
|
||||
expect(hasDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for duplicates on the same shortcut", () => {
|
||||
const hasDuplicate = isDuplicateCombination(
|
||||
shortcuts,
|
||||
"Open Playground",
|
||||
"mod+k",
|
||||
);
|
||||
expect(hasDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes recorded combinations", () => {
|
||||
expect(normalizeRecordedCombination("Ctrl + K")).toBe("mod+k");
|
||||
expect(normalizeRecordedCombination("Cmd + Shift + P")).toBe("mod+shift+p");
|
||||
});
|
||||
|
||||
it("builds fixed combinations", () => {
|
||||
expect(getFixedCombination(null, "space")).toBe("Space");
|
||||
expect(getFixedCombination("Ctrl", "k")).toBe("Ctrl + K");
|
||||
});
|
||||
|
||||
it("checks for existing keys", () => {
|
||||
expect(checkForKeys("Ctrl + K", "Ctrl")).toBe(true);
|
||||
expect(checkForKeys("Ctrl + K", "Shift")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import EditShortcutButton from "../EditShortcutButton";
|
||||
|
||||
const mockSetSuccessData = jest.fn();
|
||||
const mockSetErrorData = jest.fn();
|
||||
const mockSetShortcuts = jest.fn();
|
||||
const mockUpdateUniqueShortcut = jest.fn();
|
||||
|
||||
type AlertStoreState = {
|
||||
setSuccessData: typeof mockSetSuccessData;
|
||||
setErrorData: typeof mockSetErrorData;
|
||||
};
|
||||
|
||||
type ShortcutsStoreState = {
|
||||
setShortcuts: typeof mockSetShortcuts;
|
||||
updateUniqueShortcut: typeof mockUpdateUniqueShortcut;
|
||||
};
|
||||
|
||||
jest.mock("@/stores/alertStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector: (state: AlertStoreState) => unknown) =>
|
||||
selector({
|
||||
setSuccessData: mockSetSuccessData,
|
||||
setErrorData: mockSetErrorData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/shortcuts", () => ({
|
||||
__esModule: true,
|
||||
useShortcutsStore: (selector: (state: ShortcutsStoreState) => unknown) =>
|
||||
selector({
|
||||
setShortcuts: mockSetShortcuts,
|
||||
updateUniqueShortcut: mockUpdateUniqueShortcut,
|
||||
}),
|
||||
}));
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
jest.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: ButtonProps) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
"@/components/common/renderIconComponent/components/renderKey",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
default: ({ name }: { name: string }) => (
|
||||
<span data-testid={`icon-${name}`}>{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/modals/baseModal", () => {
|
||||
interface ChildrenProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface HeaderProps extends ChildrenProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface TriggerProps extends ChildrenProps {
|
||||
disable?: boolean;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
interface BaseModalProps extends ChildrenProps {
|
||||
open?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
const MockContent = ({ children }: ChildrenProps) => (
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
);
|
||||
const MockHeader = ({ children, description }: HeaderProps) => (
|
||||
<div data-testid="modal-header" data-description={description}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const MockTrigger = ({ children, disable }: TriggerProps) => (
|
||||
<div data-testid="modal-trigger" data-disabled={disable}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const MockFooter = ({ children }: ChildrenProps) => (
|
||||
<div data-testid="modal-footer">{children}</div>
|
||||
);
|
||||
|
||||
function MockBaseModal({ children, open, size }: BaseModalProps) {
|
||||
if (!open) {
|
||||
return <div data-testid="base-modal-closed" data-size={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="base-modal" data-size={size}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MockContent.displayName = "Content";
|
||||
MockHeader.displayName = "Header";
|
||||
MockTrigger.displayName = "Trigger";
|
||||
MockFooter.displayName = "Footer";
|
||||
|
||||
MockBaseModal.Content = MockContent;
|
||||
MockBaseModal.Header = MockHeader;
|
||||
MockBaseModal.Trigger = MockTrigger;
|
||||
MockBaseModal.Footer = MockFooter;
|
||||
|
||||
return { __esModule: true, default: MockBaseModal };
|
||||
});
|
||||
|
||||
describe("EditShortcutButton", () => {
|
||||
let setItemSpy: jest.SpyInstance<void, [string, string]>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setItemSpy = jest
|
||||
.spyOn(Storage.prototype, "setItem")
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resets shortcut to default value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const shortcuts = [
|
||||
{ name: "Docs", display_name: "Docs", shortcut: "mod+shift+d" },
|
||||
{ name: "Code", display_name: "Code", shortcut: "mod+." },
|
||||
];
|
||||
const defaultShortcuts = [
|
||||
{ name: "Docs", display_name: "Docs", shortcut: "mod+shift+d" },
|
||||
{ name: "Code", display_name: "Code", shortcut: "space" },
|
||||
];
|
||||
|
||||
const setOpen = jest.fn();
|
||||
const setSelected = jest.fn();
|
||||
|
||||
render(
|
||||
<EditShortcutButton
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
shortcut={["Code"]}
|
||||
shortcuts={shortcuts}
|
||||
defaultShortcuts={defaultShortcuts}
|
||||
setSelected={setSelected}
|
||||
>
|
||||
<div />
|
||||
</EditShortcutButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reset" }));
|
||||
|
||||
expect(mockSetShortcuts).toHaveBeenCalledWith([
|
||||
{ name: "Docs", display_name: "Docs", shortcut: "mod+shift+d" },
|
||||
{ name: "Code", display_name: "Code", shortcut: "space" },
|
||||
]);
|
||||
expect(mockUpdateUniqueShortcut).toHaveBeenCalledWith("code", "space");
|
||||
expect(mockSetSuccessData).toHaveBeenCalledWith({
|
||||
title: "Code shortcut reset to default",
|
||||
});
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"langflow-shortcuts",
|
||||
JSON.stringify([
|
||||
{ name: "Docs", display_name: "Docs", shortcut: "mod+shift+d" },
|
||||
{ name: "Code", display_name: "Code", shortcut: "space" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,8 @@ export default function ShortcutsPage() {
|
||||
<EditShortcutButton
|
||||
disable={selectedRows.length === 0}
|
||||
shortcut={selectedRows}
|
||||
defaultShortcuts={shortcuts}
|
||||
shortcuts={shortcuts}
|
||||
defaultShortcuts={defaultShortcuts}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
setSelected={setSelectedRows}
|
||||
|
||||
@@ -15,7 +15,19 @@ type Message = {
|
||||
category?: string;
|
||||
properties?: {
|
||||
state?: "partial" | "complete";
|
||||
source?: { id?: string };
|
||||
source?: {
|
||||
id?: string;
|
||||
display_name?: string;
|
||||
source?: string;
|
||||
};
|
||||
icon?: string;
|
||||
background_color?: string;
|
||||
text_color?: string;
|
||||
targets?: string[];
|
||||
edited?: boolean;
|
||||
allow_markdown?: boolean;
|
||||
positive_feedback?: boolean | null;
|
||||
build_duration?: number | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content_blocks?: ContentBlock[];
|
||||
|
||||
17
src/frontend/src/types/messages/session.ts
Normal file
17
src/frontend/src/types/messages/session.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface DeleteSessionParams {
|
||||
sessionId: string;
|
||||
flowId?: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionError {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { expect, test } from "../../fixtures";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
import { getAllResponseMessage } from "../../utils/get-all-response-message";
|
||||
import { initialGPTsetup } from "../../utils/initialGPTsetup";
|
||||
import { disableInspectPanel } from "../../utils/open-advanced-options";
|
||||
import { unselectNodes } from "../../utils/unselect-nodes";
|
||||
import { waitForOpenModalWithChatInput } from "../../utils/wait-for-open-modal";
|
||||
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
|
||||
import { unselectNodes } from "../../utils/unselect-nodes";
|
||||
import { disableInspectPanel } from "../../utils/open-advanced-options";
|
||||
|
||||
withEventDeliveryModes(
|
||||
"Market Research",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "../../fixtures";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
test.describe("Invalid JSON Upload Error Handling", () => {
|
||||
// Helper function to verify error appears
|
||||
async function verifyErrorAppears(page: Page) {
|
||||
// Wait for error alert to appear
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const statusElements = await page.locator('[role="status"]').all();
|
||||
|
||||
let errorFound = false;
|
||||
|
||||
if (statusElements.length > 0) {
|
||||
for (const element of statusElements) {
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
const text = await element.textContent();
|
||||
if (text && /error|upload|json|parse/i.test(text.toLowerCase())) {
|
||||
errorFound = true;
|
||||
expect(text).toBeTruthy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorFound) {
|
||||
const errorTextLocator = page.getByText(/Error/i).first();
|
||||
const errorVisible = await errorTextLocator
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (errorVisible) {
|
||||
const text = await errorTextLocator.textContent();
|
||||
expect(text?.toLowerCase()).toMatch(/error/i);
|
||||
errorFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(errorFound).toBeTruthy();
|
||||
}
|
||||
|
||||
test(
|
||||
"should show error popup when uploading invalid JSON via upload button",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Navigate to main page
|
||||
await page.goto("/");
|
||||
await page.waitForSelector('[data-testid="mainpage_title"]', {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Create an invalid JSON file content
|
||||
const invalidJsonContent = '{"invalid": }';
|
||||
|
||||
// Wait for the upload button in the sidebar
|
||||
await page.waitForSelector('[data-testid="upload-project-button"]', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Set up file chooser handler before clicking
|
||||
const fileChooserPromise = page.waitForEvent("filechooser", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Click the upload button
|
||||
await page.getByTestId("upload-project-button").last().click();
|
||||
|
||||
// Handle the file chooser
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles({
|
||||
name: "invalid-flow.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(invalidJsonContent),
|
||||
});
|
||||
|
||||
// Verify error appears
|
||||
await verifyErrorAppears(page);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show error popup when uploading invalid JSON via drag and drop",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Navigate to main page
|
||||
await page.goto("/");
|
||||
await page.waitForSelector('[data-testid="mainpage_title"]', {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Create invalid JSON file content
|
||||
const invalidJsonContent = '{"invalid": json content}';
|
||||
|
||||
const dataTransfer = await page.evaluateHandle((data) => {
|
||||
const dt = new DataTransfer();
|
||||
const file = new File([data], "invalid-flow.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
dt.items.add(file);
|
||||
return dt;
|
||||
}, invalidJsonContent);
|
||||
|
||||
await page.getByTestId("cards-wrapper").dispatchEvent("drop", {
|
||||
dataTransfer,
|
||||
});
|
||||
await verifyErrorAppears(page);
|
||||
},
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user