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:
Adam-Aghili
2026-03-06 16:53:00 -05:00
committed by GitHub
parent f75b307621
commit 13ecd4f295
111 changed files with 5647 additions and 2744 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View 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"

View File

@@ -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>
);
};

View File

@@ -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"]],
});
},
},
);
});
}
});
});
});
};

View File

@@ -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={[]}

View File

@@ -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(

View File

@@ -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()}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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",
);
});
});

View File

@@ -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>

View File

@@ -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,
)}

View File

@@ -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}

View File

@@ -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`}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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}
/>
))}

View File

@@ -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,
};
});

View File

@@ -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";

View File

@@ -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,
)}

View File

@@ -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;
}

View File

@@ -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}

View 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);
});
});

View File

@@ -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: {

View File

@@ -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,
)}
>

View File

@@ -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}

View File

@@ -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);
},
});

View File

@@ -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();
});
});
});

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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);
}
}
}

View File

@@ -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",
)}
>

View File

@@ -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]);

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -156,8 +156,10 @@ export function StepConfiguration({
<input
id="folder-input"
type="file"
multiple
className="hidden"
onChange={onFolderSelect}
accept={ACCEPTED_FILE_TYPES}
{...({
webkitdirectory: "",
directory: "",

View File

@@ -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"],

View File

@@ -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 = "";
};

View File

@@ -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",
)}
>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,
)}
>

View File

@@ -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">

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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("");
});
});
});

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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 "";
};

View File

@@ -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);

View File

@@ -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)],
});
});
}

View File

@@ -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`,
});

View File

@@ -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();
});
});

View File

@@ -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"

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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" },
]),
);
});
});

View File

@@ -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}

View File

@@ -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[];

View 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;
}

View File

@@ -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",

View File

@@ -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